import { useEffect, useRef, useState } from "react"; import { api, CardRef, Deck } from "./api"; import { DialogFrame, useClickPreview } from "./components"; // Kept in step with App.tsx's Tab union by hand — it isn't exported. type Tab = "add" | "library" | "search" | "decks" | "game" | "news" | "info"; interface PaletteItem { key: string; label: string; hint?: string; run: () => void; } /** Simple, dependency-free case-insensitive subsequence match — e.g. "gtd" * matches "Go to Decks". Good enough for the small static command list and * the deck list; card search still defers to the server-side fuzzy matcher * via `api.searchCards`. */ function subsequenceMatch(query: string, text: string): boolean { if (!query) return true; const q = query.toLowerCase(); const t = text.toLowerCase(); let qi = 0; for (let ti = 0; ti < t.length && qi < q.length; ti++) { if (t[ti] === q[qi]) qi++; } return qi === q.length; } /** * Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and * cards. Selecting a deck switches to the Decks tab with it pre-selected; * selecting a card opens the existing click-to-zoom preview modal without * changing tabs. When an undoable action's grace window hasn't lapsed yet, * an "Undo: …" entry is listed first, so Alt+/ then Enter is a fast way to * undo without hunting for the floating snackbar's button. */ export function CommandPalette({ onNavigate, onUpdateCardData, onSyncNow, undo, onRunUndo, }: { onNavigate: (tab: Tab) => void; onUpdateCardData: () => void; onSyncNow: () => void; /** The most recent undoable action, if its window hasn't lapsed yet. */ undo: { message: string; run: () => void | Promise } | null; onRunUndo: () => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [decks, setDecks] = useState([]); const [cardResults, setCardResults] = useState([]); const [sel, setSel] = useState(0); const inputRef = useRef(null); const { open: openCardPreview } = useClickPreview(); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.altKey && e.key === "/") { e.preventDefault(); setOpen((o) => !o); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); useEffect(() => { if (!open) return; setQuery(""); setCardResults([]); api.listDecks().then(setDecks); setTimeout(() => inputRef.current?.focus(), 0); }, [open]); useEffect(() => { if (!open) return; const q = query.trim(); if (!q) return setCardResults([]); const t = setTimeout(() => api.searchCards(q, 8).then(setCardResults), 80); return () => clearTimeout(t); }, [query, open]); function close() { setOpen(false); } const staticCommands: PaletteItem[] = [ ...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []), { key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") }, { key: "nav-library", label: "Go to Library", run: () => onNavigate("library") }, { key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") }, { key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") }, { key: "nav-game", label: "Go to Game night (life counter, dice)", run: () => onNavigate("game") }, { key: "nav-news", label: "Go to News", run: () => onNavigate("news") }, { key: "update-card-data", label: "Update card data", run: onUpdateCardData }, { key: "sync-now", label: "Sync library now", run: onSyncNow }, { key: "nav-sync", label: "Sync settings", run: () => onNavigate("info") }, ]; const items: PaletteItem[] = [ ...staticCommands.filter((c) => subsequenceMatch(query, c.label)), ...decks .filter((d) => subsequenceMatch(query, d.name)) .map((d) => ({ key: `deck-${d.id}`, label: d.name, hint: "Deck", run: () => { // DeckView reads its initial selection from this same // localStorage key on mount (usePersistedState("deck-selected"), // src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on // tab switch, so writing here before navigating is enough for it // to pick up the selection on its next mount. localStorage.setItem("deck-selected", JSON.stringify(d.id)); onNavigate("decks"); }, })), ...cardResults.map((c) => ({ key: `card-${c.oracle_id}`, label: c.name, hint: c.type_line, run: () => openCardPreview(c), })), ].slice(0, 20); useEffect(() => setSel(0), [items.length, query]); function pick(item: PaletteItem) { item.run(); close(); } function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(s + 1, items.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); } else if (e.key === "Enter" && items[sel]) { e.preventDefault(); pick(items[sel]); } } if (!open) return null; return ( setQuery(e.target.value)} onKeyDown={onKeyDown} /> {items.length > 0 && (
    {items.map((item, i) => (
  • setSel(i)} onClick={() => pick(item)} > {item.label} {item.hint && {item.hint}}
  • ))}
)}
); }