2026-07-20 00:21:22 -05:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
|
import { api, CardRef, Deck } from "./api";
|
|
|
|
|
import { DialogFrame, useClickPreview } from "./components";
|
|
|
|
|
|
Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.
Two sources with very different reliability, kept apart so one failing can't
take the page down.
Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.
Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.
Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.
get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.
Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.
Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
|
|
|
// Kept in step with App.tsx's Tab union by hand — it isn't exported.
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
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.
2026-10-14 22:09:39 -05:00
|
|
|
type Tab = "add" | "library" | "search" | "decks" | "game" | "news" | "info";
|
2026-07-20 00:21:22 -05:00
|
|
|
|
|
|
|
|
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
|
2026-07-21 21:04:36 -05:00
|
|
|
* 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.
|
2026-07-20 00:21:22 -05:00
|
|
|
*/
|
|
|
|
|
export function CommandPalette({
|
|
|
|
|
onNavigate,
|
|
|
|
|
onUpdateCardData,
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
onSyncNow,
|
2026-07-21 21:04:36 -05:00
|
|
|
undo,
|
|
|
|
|
onRunUndo,
|
2026-07-20 00:21:22 -05:00
|
|
|
}: {
|
|
|
|
|
onNavigate: (tab: Tab) => void;
|
|
|
|
|
onUpdateCardData: () => void;
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
onSyncNow: () => void;
|
2026-07-21 21:04:36 -05:00
|
|
|
/** The most recent undoable action, if its window hasn't lapsed yet. */
|
|
|
|
|
undo: { message: string; run: () => void | Promise<void> } | null;
|
|
|
|
|
onRunUndo: () => void;
|
2026-07-20 00:21:22 -05:00
|
|
|
}) {
|
|
|
|
|
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[] = [
|
2026-07-21 21:04:36 -05:00
|
|
|
...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []),
|
2026-07-20 00:21:22 -05:00
|
|
|
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
|
|
|
|
|
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
2026-07-22 20:55:17 -05:00
|
|
|
{ key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") },
|
2026-07-20 00:21:22 -05:00
|
|
|
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
Spare/rarity filters, owned-only suggestions, and a Game night tab
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.
2026-10-14 22:09:39 -05:00
|
|
|
{ key: "nav-game", label: "Go to Game night (life counter, dice)", run: () => onNavigate("game") },
|
Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.
Two sources with very different reliability, kept apart so one failing can't
take the page down.
Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.
Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.
Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.
get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.
Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.
Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
|
|
|
{ key: "nav-news", label: "Go to News", run: () => onNavigate("news") },
|
2026-07-20 00:21:22 -05:00
|
|
|
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
{ key: "sync-now", label: "Sync library now", run: onSyncNow },
|
|
|
|
|
{ key: "nav-sync", label: "Sync settings", run: () => onNavigate("info") },
|
2026-07-20 00:21:22 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|