import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Eye, EyeOff, Keyboard, Moon, PiggyBank, Search, Sun } from "lucide-react"; import { PAGES, OPEN_PALETTE_EVENT } from "./commandPaletteConfig"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from "@/components/ui/command"; import { useFinanceData } from "@/context/FinanceDataContext"; import { useTheme } from "@/context/ThemeContext"; import { CATEGORIES } from "@/lib/categories"; import { toggleAmountsHidden, usePrivacy } from "@/lib/privacy"; import { useHotkeys, MOD_LABEL } from "@/hooks/useHotkeys"; import { CategoryIcon } from "@/components/elements/CategoryIcon"; import { accountDisplayName, canHoldTransactions } from "@/lib/accounts"; function paletteFilter(value: string, search: string) { const v = value.toLowerCase(); const q = search.trim().toLowerCase(); if (!q) return 1; if (v.split(/[\s&/-]+/).some((w) => w.startsWith(q))) return 2; return v.includes(q) ? 1 : 0; } const keyLabel = (keys: string) => keys.split(" ").map((k) => k.toUpperCase()).join(" "); /** * ⌘K palette plus the app-wide shortcuts: jump to a page, an account's or a * category's transactions, search transactions, hide amounts, switch theme. * Mounted once per app page by Layout. */ export function CommandPalette() { const navigate = useNavigate(); const { accounts: allAccounts } = useFinanceData(); const accounts = allAccounts.filter((a) => canHoldTransactions(a.type)); const { theme, toggleTheme } = useTheme(); const hidden = usePrivacy(); const [open, setOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [query, setQuery] = useState(""); useEffect(() => { const onOpen = () => setOpen(true); window.addEventListener(OPEN_PALETTE_EVENT, onOpen); return () => window.removeEventListener(OPEN_PALETTE_EVENT, onOpen); }, []); useHotkeys({ "mod+k": () => setOpen((o) => !o), "shift+h": () => toggleAmountsHidden(), "?": () => setHelpOpen(true), ...Object.fromEntries(PAGES.map((p) => [p.keys, () => navigate(p.to)])), }); const run = (fn: () => void) => { setOpen(false); setQuery(""); fn(); }; const q = query.trim(); return ( <> { setOpen(o); if (!o) setQuery(""); }}> Command palette Jump to a page, account or category, or run an action. Nothing matches. {PAGES.map((p) => ( run(() => navigate(p.to))} className="gap-2"> {p.label} {keyLabel(p.keys)} ))} run(toggleAmountsHidden)} className="gap-2"> {hidden ? : } {hidden ? "Show amounts" : "Hide amounts"} ⇧H run(toggleTheme)} className="gap-2"> {theme === "dark" ? : } Switch to {theme === "dark" ? "light" : "dark"} theme run(() => setHelpOpen(true))} className="gap-2"> Keyboard shortcuts ? {accounts.length > 0 && ( <> {accounts.map((a) => ( run(() => navigate(`/transactions?account=${encodeURIComponent(a.id)}`))} className="gap-2"> {accountDisplayName(a)} {a.institution} ))} > )} {CATEGORIES.map((c) => ( run(() => navigate(`/transactions?category=${encodeURIComponent(c)}`))} className="gap-2"> {c} ))} {/* Always offered, but its value doesn't contain the query, so it ranks below any real match: "groceries" + Enter opens the category, while a merchant name with no other match searches for it. */} {q && ( run(() => navigate(`/transactions?q=${encodeURIComponent(q)}`))} className="gap-2"> Search transactions for “{q}” )} > ); } const SHORTCUTS: { group: string; items: [string, string][] }[] = [ { group: "Anywhere", items: [[`${MOD_LABEL} K`, "Command palette"], ["⇧ H", "Hide or show amounts"], ["?", "This list"]] }, { group: "Go to", items: PAGES.map((p) => [keyLabel(p.keys), p.label] as [string, string]) }, { group: "Transactions", items: [["/", "Search"], ["J / K", "Next / previous row"], ["Enter", "Open row"], ["X", "Select row"], ["Esc", "Clear search"]] }, ]; function ShortcutsHelp({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { return ( Keyboard shortcuts Every keyboard shortcut in the app. {SHORTCUTS.map((g) => ( {g.group} {g.items.map(([keys, what]) => ( {what} {keys.split(" / ").map((k) => ( {k} ))} ))} ))} ); }