diff options
Diffstat (limited to 'apps/web/app/reader/_components/command-palette.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/command-palette.tsx | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/command-palette.tsx b/apps/web/app/reader/_components/command-palette.tsx new file mode 100644 index 0000000..f3ff992 --- /dev/null +++ b/apps/web/app/reader/_components/command-palette.tsx @@ -0,0 +1,200 @@ +"use client" + +import { Command } from "cmdk" +import { useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" + +export function CommandPalette() { + const isOpen = useUserInterfaceStore((state) => state.isCommandPaletteOpen) + const setOpen = useUserInterfaceStore((state) => state.setCommandPaletteOpen) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const router = useRouter() + const { data: subscriptionsData } = useSubscriptions() + const listReference = useRef<HTMLDivElement>(null) + const [inputValue, setInputValue] = useState("") + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + setOpen(!isOpen) + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false) + return + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + setTimeout(() => { + const list = listReference.current + if (!list) return + const selected = list.querySelector('[aria-selected="true"]') as HTMLElement + if (!selected) return + const listRect = list.getBoundingClientRect() + const selectedRect = selected.getBoundingClientRect() + if (selectedRect.bottom > listRect.bottom) { + list.scrollTop += selectedRect.bottom - listRect.bottom + } else if (selectedRect.top < listRect.top) { + list.scrollTop -= listRect.top - selectedRect.top + } + }, 0) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + if (!isOpen) return null + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && inputValue === "") { + event.preventDefault() + setOpen(false) + } + } + + function navigateAndClose(path: string) { + router.push(path) + setOpen(false) + } + + function actionAndClose(action: () => void) { + action() + setOpen(false) + } + + return ( + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={() => setOpen(false)} + /> + <Command className="relative w-full max-w-lg border border-border bg-background-secondary"> + <Command.Input + placeholder="type a command..." + className="w-full border-b border-border bg-transparent px-4 py-3 text-text-primary outline-none placeholder:text-text-dim" + autoFocus + value={inputValue} + onValueChange={setInputValue} + onKeyDown={handleInputKeyDown} + /> + <Command.List ref={listReference} className="max-h-80 overflow-auto p-2"> + <Command.Empty className="p-4 text-center text-text-dim"> + no results found + </Command.Empty> + + <Command.Group + heading="navigation" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => navigateAndClose("/reader")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to all entries + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/saved")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to saved + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/settings")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to settings + </Command.Item> + </Command.Group> + + {subscriptionsData && + subscriptionsData.subscriptions.length > 0 && ( + <Command.Group + heading="feeds" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + {subscriptionsData.subscriptions.map((subscription) => ( + <Command.Item + key={subscription.subscriptionIdentifier} + value={`feed-${subscription.subscriptionIdentifier}-${subscription.customTitle ?? subscription.feedTitle}`} + onSelect={() => + navigateAndClose( + `/reader?feed=${subscription.feedIdentifier}` + ) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + {subscription.customTitle ?? subscription.feedTitle} + </Command.Item> + ))} + </Command.Group> + )} + + <Command.Group + heading="actions" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => + actionAndClose(() => setAddFeedDialogOpen(true)) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + add feed + </Command.Item> + <Command.Item + onSelect={() => actionAndClose(toggleSidebar)} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + toggle sidebar + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("compact")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + compact view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("comfortable")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + comfortable view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("expanded")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + expanded view + </Command.Item> + </Command.Group> + </Command.List> + </Command> + </div> + ) +} |