mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
"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>
|
|
)
|
|
}
|