Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
181 lines
6.0 KiB
TypeScript
181 lines
6.0 KiB
TypeScript
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<void> } | null;
|
|
onRunUndo: () => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [decks, setDecks] = useState<Deck[]>([]);
|
|
const [cardResults, setCardResults] = useState<CardRef[]>([]);
|
|
const [sel, setSel] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(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 (
|
|
<DialogFrame title="Command palette" onCancel={close} wide>
|
|
<input
|
|
ref={inputRef}
|
|
autoFocus
|
|
style={{ width: "100%", marginBottom: 8 }}
|
|
placeholder="Type a command, deck, or card name…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
{items.length > 0 && (
|
|
<ul className="result-list" style={{ marginBottom: 0 }}>
|
|
{items.map((item, i) => (
|
|
<li
|
|
key={item.key}
|
|
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
|
|
onMouseEnter={() => setSel(i)}
|
|
onClick={() => pick(item)}
|
|
>
|
|
<span className="card-name">{item.label}</span>
|
|
{item.hint && <span className="card-type">{item.hint}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</DialogFrame>
|
|
);
|
|
}
|