feat: command palette (#140)

* wip

* feat: new command palette

* chore: add universal search

* chore: cleanup

* feat: use title class for heading

* feat: add topic

* chore: advance search
This commit is contained in:
Aslam
2024-09-06 16:22:48 +07:00
committed by GitHub
parent 2bebcbc20a
commit c3e99d1366
11 changed files with 548 additions and 181 deletions

View File

@@ -0,0 +1,67 @@
import { icons } from "lucide-react"
import { useCommandActions } from "./hooks/use-command-actions"
import { LaAccount } from "@/lib/schema"
export type CommandAction = string | (() => void)
export type CommandItemType = {
icon?: keyof typeof icons
label: string
action: CommandAction
payload?: any
shortcut?: string
}
export type CommandGroupType = {
heading?: string
items: CommandItemType[]
}[]
export const createCommandGroups = (
actions: ReturnType<typeof useCommandActions>,
me: LaAccount
): Record<string, CommandGroupType> => ({
home: [
{
heading: "General",
items: [
{ icon: "SunMoon", label: "Change Theme...", action: "CHANGE_PAGE", payload: "changeTheme" },
{
icon: "Copy",
label: "Copy Current URL",
action: actions.copyCurrentURL
}
]
},
{
heading: "Personal Links",
items: [
{ icon: "TextSearch", label: "Search Links...", action: "CHANGE_PAGE", payload: "searchLinks" },
{ icon: "Plus", label: "Create New Link...", action: () => actions.navigateTo("/") }
]
},
{
heading: "Personal Pages",
items: [
{ icon: "FileSearch", label: "Search Pages...", action: "CHANGE_PAGE", payload: "searchPages" },
{
icon: "Plus",
label: "Create New Page...",
action: () => actions.createNewPage(me)
}
]
}
],
searchLinks: [],
searchPages: [],
topics: [],
changeTheme: [
{
items: [
{ icon: "Moon", label: "Change Theme to Dark", action: () => actions.changeTheme("dark") },
{ icon: "Sun", label: "Change Theme to Light", action: () => actions.changeTheme("light") },
{ icon: "Monitor", label: "Change Theme to System", action: () => actions.changeTheme("system") }
]
}
]
})

View File

@@ -0,0 +1,42 @@
import { Command } from "cmdk"
import { CommandSeparator, CommandShortcut } from "@/components/ui/command"
import { LaIcon } from "../la-icon"
import { CommandItemType, CommandAction } from "./command-data"
export interface CommandItemProps extends Omit<CommandItemType, "action"> {
action: CommandAction
handleAction: (action: CommandAction, payload?: any) => void
}
export const CommandItem: React.FC<CommandItemProps> = ({ icon, label, action, payload, shortcut, handleAction }) => (
<Command.Item onSelect={() => handleAction(action, payload)}>
{icon && <LaIcon name={icon} />}
<span>{label}</span>
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
</Command.Item>
)
export interface CommandGroupProps {
heading?: string
items: CommandItemType[]
handleAction: (action: CommandAction, payload?: any) => void
isLastGroup: boolean
}
export const CommandGroup: React.FC<CommandGroupProps> = ({ heading, items, handleAction, isLastGroup }) => {
return (
<>
{heading ? (
<Command.Group heading={heading}>
{items.map((item, index) => (
<CommandItem key={`${heading}-${item.label}-${index}`} {...item} handleAction={handleAction} />
))}
</Command.Group>
) : (
items.map((item, index) => (
<CommandItem key={`item-${item.label}-${index}`} {...item} handleAction={handleAction} />
))
)}
{!isLastGroup && <CommandSeparator className="my-1.5" />}
</>
)
}

View File

@@ -0,0 +1,230 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Command } from "cmdk"
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { CommandGroup } from "./command-items"
import { CommandAction, CommandItemType, createCommandGroups } from "./command-data"
import { useAccount } from "@/lib/providers/jazz-provider"
import { searchSafeRegExp, toTitleCase } from "@/lib/utils"
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
import { useCommandActions } from "./hooks/use-command-actions"
let graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
items.filter(item => searchRegex.test(item.label)).slice(0, 6)
export function CommandPalette() {
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
const dialogRef = React.useRef<HTMLDivElement | null>(null)
const [inputValue, setInputValue] = React.useState("")
const [activePage, setActivePage] = React.useState("home")
const [open, setOpen] = React.useState(false)
const actions = useCommandActions()
const commandGroups = React.useMemo(() => me && createCommandGroups(actions, me), [actions, me])
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(prev => !prev)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
const bounce = React.useCallback(() => {
if (dialogRef.current) {
dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
setTimeout(() => {
if (dialogRef.current) {
dialogRef.current.style.transform = ""
}
}, 100)
}
}, [])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
bounce()
}
if (activePage !== "home" && !inputValue && e.key === "Backspace") {
e.preventDefault()
setActivePage("home")
setInputValue("")
bounce()
}
},
[activePage, inputValue, bounce]
)
const allCommands = React.useMemo(() => {
if (!commandGroups) return []
return Object.entries(commandGroups).map(([key, value]) => ({
heading: toTitleCase(key),
items: value.flatMap(subgroup => subgroup.items)
}))
}, [commandGroups])
const topics = React.useMemo(
() => ({
heading: "Topics",
items: raw_graph_data.map(topic => ({
icon: "Circle" as const,
label: topic?.prettyName || "",
action: () => actions.navigateTo(`/${topic?.name}`)
}))
}),
[raw_graph_data, actions]
)
const personalLinks = React.useMemo(
() => ({
heading: "Personal Links",
items:
me?.root.personalLinks?.map(link => ({
icon: "Link" as const,
label: link?.title || "Untitled",
action: () => actions.openLinkInNewTab(link?.url || "#")
})) || []
}),
[me?.root.personalLinks, actions]
)
const personalPages = React.useMemo(
() => ({
heading: "Personal Pages",
items:
me?.root.personalPages?.map(page => ({
icon: "FileText" as const,
label: page?.title || "Untitled",
action: () => actions.navigateTo(`/pages/${page?.id}`)
})) || []
}),
[me?.root.personalPages, actions]
)
const getFilteredCommands = React.useCallback(() => {
if (!commandGroups) return []
const searchRegex = searchSafeRegExp(inputValue)
if (activePage === "home") {
if (!inputValue) return commandGroups.home
return [...allCommands, personalLinks, personalPages, topics]
.map(group => ({
heading: group.heading,
items: filterItems(group.items, searchRegex)
}))
.filter(group => group.items.length > 0)
}
switch (activePage) {
case "searchLinks":
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
case "searchPages":
return [...commandGroups.searchPages, { items: filterItems(personalPages.items, searchRegex) }]
default:
const pageCommands = commandGroups[activePage]
if (!inputValue) return pageCommands
return pageCommands
.map(group => ({
heading: group.heading,
items: filterItems(group.items, searchRegex)
}))
.filter(group => group.items.length > 0)
}
}, [inputValue, activePage, allCommands, personalLinks, personalPages, commandGroups, topics])
const handleAction = React.useCallback(
(action: CommandAction, payload?: any) => {
const closeDialog = () => {
setOpen(false)
}
if (typeof action === "function") {
action()
closeDialog()
return
}
switch (action) {
case "CHANGE_PAGE":
if (payload) {
setActivePage(payload)
setInputValue("")
bounce()
} else {
console.error(`Invalid page: ${payload}`)
}
break
default:
console.log(`Unhandled action: ${action}`)
closeDialog()
}
},
[bounce]
)
const filteredCommands = React.useMemo(() => getFilteredCommands(), [getFilteredCommands])
const commandKey = React.useMemo(() => {
return filteredCommands
.map(group => {
const itemsKey = group.items.map(item => `${item.label}-${item.action}`).join("|")
return `${group.heading}:${itemsKey}`
})
.join("__")
}, [filteredCommands])
if (!me) return null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogPortal>
<DialogPrimitive.Overlay la-overlay="" cmdk-overlay="" />
<DialogPrimitive.Content la-dialog="" cmdk-dialog="" className="la" ref={dialogRef}>
<DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle>
<DialogDescription>Search for commands and actions</DialogDescription>
</DialogHeader>
<Command key={commandKey} onKeyDown={handleKeyDown}>
<div cmdk-input-wrapper="">
<Command.Input
autoFocus
placeholder="Type a command or search..."
value={inputValue}
onValueChange={setInputValue}
/>
</div>
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{filteredCommands.map((group, index, array) => (
<CommandGroup
key={`${group.heading}-${index}`}
heading={group.heading}
items={group.items}
handleAction={handleAction}
isLastGroup={index === array.length - 1}
/>
))}
</Command.List>
</Command>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
)
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { ensureUrlProtocol } from "@/lib/utils"
import { useTheme } from "next-themes"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { LaAccount, PersonalPage } from "@/lib/schema"
export const useCommandActions = () => {
const { setTheme } = useTheme()
const router = useRouter()
const changeTheme = React.useCallback(
(theme: string) => {
setTheme(theme)
toast.success(`Theme changed to ${theme}.`, { position: "bottom-right" })
},
[setTheme]
)
const navigateTo = React.useCallback(
(path: string) => {
router.push(path)
},
[router]
)
const openLinkInNewTab = React.useCallback((url: string) => {
window.open(ensureUrlProtocol(url), "_blank")
}, [])
const copyCurrentURL = React.useCallback(() => {
navigator.clipboard.writeText(window.location.href)
toast.success("URL copied to clipboard.", { position: "bottom-right" })
}, [])
const createNewPage = React.useCallback(
(me: LaAccount) => {
try {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
},
[router]
)
return {
changeTheme,
navigateTo,
openLinkInNewTab,
copyCurrentURL,
createNewPage
}
}

View File

@@ -10,7 +10,7 @@ let graph_data_promise = import("./graph-data.json").then(a => a.default)
const ForceGraphClient = dynamic(() => import("./force-graph-client-lazy"), { ssr: false })
interface GraphNode {
export interface GraphNode {
name: string
prettyName: string
connectedTopics: string[]

View File

@@ -1,177 +0,0 @@
"use client"
import { AnimatePresence, motion } from "framer-motion"
import { useEffect, useState, KeyboardEvent as ReactKeyboardEvent } from "react"
import { Icon } from "../la-editor/components/ui/icon"
import { linkShowCreateAtom } from "@/store/link"
import { generateUniqueSlug } from "@/lib/utils"
import { useAtom } from "jotai"
import { PersonalPage } from "@/lib/schema/personal-page"
import { useRouter } from "next/navigation"
import { useAccount } from "@/lib/providers/jazz-provider"
import { toast } from "sonner"
export function CommandPalette() {
const [showPalette, setShowPalette] = useState(false)
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const router = useRouter()
const { me } = useAccount()
const [commands, setCommands] = useState<
{ name: string; icon?: React.ReactNode; keybind?: string[]; action: () => void }[]
>([
{
name: "Create new link",
icon: <Icon name="Link" />,
// keybind: ["Ctrl", "K"],
action: () => {
if (window.location.pathname !== "/") {
router.push("/")
}
setShowCreate(true)
}
},
{
name: "Create page",
icon: <Icon name="File" />,
// keybind: ["Ctrl", "P"],
action: () => {
const personalPages = me?.root?.personalPages?.toJSON() || []
const slug = generateUniqueSlug(personalPages, "Untitled Page")
const newPersonalPage = PersonalPage.create(
{
title: "Untitled Page",
slug: slug,
content: ""
},
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
}
}
// {
// name: "Assign status..",
// // icon: <Icon name="File" />,
// // keybind: ["Ctrl", "P"],
// action: () => {}
// }
])
const [searchTerm, setSearchTerm] = useState("")
const [commandResults, setCommandResults] = useState(commands)
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault()
setShowPalette(prev => !prev)
} else if (showPalette) {
if (["Escape", "Enter", "ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault()
event.stopPropagation()
// Handle the key events here
if (event.key === "Escape") {
setShowPalette(false)
} else if (event.key === "Enter" && commandResults.length > 0) {
commandResults[selectedIndex].action()
setShowPalette(false)
} else if (event.key === "ArrowDown") {
setSelectedIndex(prevIndex => (prevIndex < commandResults.length - 1 ? prevIndex + 1 : prevIndex))
} else if (event.key === "ArrowUp") {
setSelectedIndex(prevIndex => (prevIndex > 0 ? prevIndex - 1 : prevIndex))
}
}
}
}
document.addEventListener("keydown", handleKeyDown, true)
return () => {
document.removeEventListener("keydown", handleKeyDown, true)
}
}, [showPalette, commandResults, selectedIndex])
// Remove the separate handleKeyDown function for the input
// as we're now handling all key events in the global listener
if (!showPalette) return null
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed left-0 top-0 z-[100] flex h-screen w-screen justify-center pt-[100px]"
onClick={() => setShowPalette(false)}
>
<div
role="dialog"
aria-modal="true"
aria-label="Command Palette"
onClick={e => e.stopPropagation()}
className="relative h-fit w-[600px] rounded-lg border border-slate-400/20 bg-white drop-shadow-xl dark:bg-neutral-900"
>
<div className="flex items-center gap-3 border-b border-slate-400/20 p-4">
<Icon name="Search" className="h-[20px] w-[20px] opacity-70" aria-hidden="true" />
<input
type="text"
className="w-full bg-transparent text-[18px] outline-none"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search commands..."
aria-label="Search commands"
autoFocus
/>
</div>
<ul className="flex h-full max-h-[500px] flex-col gap-2 p-2 text-[12px]" role="listbox">
{commandResults.map((command, index) => (
<li
key={index}
role="option"
aria-selected={index === selectedIndex}
className={`flex w-full cursor-pointer items-center justify-between rounded-lg p-3 transition-all ${
index === selectedIndex
? "bg-gray-100 dark:bg-neutral-800"
: "hover:bg-gray-100 dark:hover:bg-neutral-800"
}`}
onClick={() => {
command.action()
setShowPalette(false)
}}
>
<div className="flex items-center gap-2">
<span className="h-4 w-4" aria-hidden="true">
{command.icon}
</span>
<span>{command.name}</span>
</div>
{command.keybind && (
<div className="flex items-center gap-1 opacity-60">
{command.keybind.map(key => (
<kbd
key={key}
className="flex h-[24px] w-fit min-w-[24px] items-center justify-center rounded-md bg-gray-200 px-2 dark:bg-neutral-700/60"
>
{key}
</kbd>
))}
</div>
)}
</li>
))}
{commandResults.length === 0 && (
<li className="p-3 text-center text-sm text-slate-400">No results found</li>
)}
</ul>
</div>
</motion.div>
</AnimatePresence>
)
}