import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { open, save } from "@tauri-apps/plugin-dialog"; import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api"; import { ColorDistributionBar, HoverPreviewProvider, IconButton, Icons, StatTile, UndoBar, usePersistedState, } from "./components"; import { CommandPalette } from "./CommandPalette"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { CardSearch } from "./views/CardSearch"; import { DeckView } from "./views/DeckView"; import { NewsView } from "./views/NewsView"; type Tab = "add" | "library" | "search" | "decks" | "news" | "info"; const svg = (children: ReactNode, size = 20) => ( {children} ); const ICON_OPEN_LIBRARY = svg( , 16, ); const ICON_NEW_LIBRARY = svg( <> , 16, ); const ICON_DOWNLOAD = svg( <> , 16, ); /** * mtgc's logomark: an original pirate-ship silhouette in the terracotta brand * color (a nod to the MTG card "Pirate Ship", not a reproduction of its art). * `gradientId` must be unique per instance rendered on the page at once. */ function ShipLogo({ size = 24, gradientId }: { size?: number; gradientId: string }) { return ( ); } const NAV: { id: Tab; label: string; icon: ReactNode }[] = [ { id: "add", label: "Add Cards", icon: svg() }, { id: "library", label: "Library", icon: svg( <> , ), }, { id: "search", label: "Card Search", icon: svg( <> , ), }, { id: "decks", label: "Decks", icon: svg( <> , ), }, { id: "news", label: "News", icon: svg( <> , ), }, ]; /** Mobile-only tab (see `.bottom-nav`) holding card-database management — * on desktop that lives in the topbar instead, so this isn't in `NAV`. */ const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = { id: "info", label: "Info", icon: svg( <> , ), }; /** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */ function formatRelativeTime(epochSeconds: number): string { const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000); if (diffSec < 30) return "just now"; const mins = Math.round(diffSec / 60); if (mins < 60) return `${mins}m ago`; const hours = Math.round(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.round(hours / 24); if (days < 30) return `${days}d ago`; const months = Math.round(days / 30); if (months < 12) return `${months}mo ago`; return `${Math.round(months / 12)}y ago`; } /** "Your library" summary: unique/copy/deck counts, color distribution, and * the reference-db freshness stamp. Shared by the desktop nav rail's footer * and the mobile Info tab, which otherwise have no equivalent place for it. */ function LibrarySummary({ ready, libStats, status, }: { ready: boolean; libStats: LibraryStats | null; status: Status | null; }) { if (!ready) { return (
No card data
); } return (
Your library
{!libStats ? (
Loading…
) : ( <>
)}
{status?.card_count.toLocaleString()} cards in database
{status?.reference_updated_at != null && (
Updated {formatRelativeTime(status.reference_updated_at)}
)}
); } /** Percent-fill (0..100) of one segment, given overall cumulative percent. */ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number { let start = 0; for (let i = 0; i < index; i++) start += stages[i].weight; const end = start + stages[index].weight; const [startPct, endPct] = [start * 100, end * 100]; if (overallPercent <= startPct) return 0; if (overallPercent >= endPct) return 100; if (endPct === startPct) return 100; return ((overallPercent - startPct) / (endPct - startPct)) * 100; } // How long the floating "Undo" snackbar stays up before auto-hiding itself. // This only controls the snackbar's visibility — the underlying undo action // stays available in the command palette well past this (see `undo` below), // so missing the snackbar isn't the last chance to undo. const UNDO_SNACKBAR_MS = 8000; /** How often an open, visible window re-syncs. Long enough not to churn the * synced folder, short enough that a second device sees edits without being * restarted. Launch and foreground/background transitions sync too. */ const AUTO_SYNC_MS = 3 * 60 * 1000; export default function App() { const [status, setStatus] = useState(null); const [libStats, setLibStats] = useState(null); const [tab, setTab] = usePersistedState("active-tab", "add"); const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); // Synced-folder grant for library sync. Only fetched when the Info tab is // open: the grant can be revoked from system settings while the app is // backgrounded, so a value cached at startup would be stale exactly when the // user comes back to check it. const [sync, setSync] = useState(null); const [syncBusy, setSyncBusy] = useState(false); // Result of the last round-trip self-test, shown until the next action. const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null); const lastProgress = useRef(null); // Set by the ingest-progress listener below; reset before each // updateCardData() call so it can detect a stalled invoke — see there. const gotProgress = useRef(false); // Lives here (not in LibraryBrowser) so it's reachable from the command // palette, which is mounted at the app level and outlives any one tab's // view across tab switches. Unlike the snackbar, this has no timeout — // it's only replaced by a newer undo offer, run, or explicitly dismissed — // so an undo that's missed in the snackbar is still reachable via Alt+/. const [undo, setUndo] = useState<{ message: string; run: () => void | Promise } | null>( null, ); const [snackbarVisible, setSnackbarVisible] = useState(false); const snackbarTimer = useRef | null>(null); // Bumped once an undo actually runs, so views mounted elsewhere than where // the undo was offered (e.g. the Decks tab, when the undo came from a // Library bulk add-to-deck) know to refetch instead of showing stale data // until the user leaves and comes back. const [deckRefreshSignal, setDeckRefreshSignal] = useState(0); function offerUndo(message: string, run: () => void | Promise) { if (snackbarTimer.current) clearTimeout(snackbarTimer.current); setUndo({ message, run }); setSnackbarVisible(true); snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS); } // Explicit "×" on the snackbar: reject the whole offer, not just hide it. function dismissUndo() { if (snackbarTimer.current) clearTimeout(snackbarTimer.current); setSnackbarVisible(false); setUndo(null); } function runUndo() { if (!undo) return; const { run } = undo; if (snackbarTimer.current) clearTimeout(snackbarTimer.current); setSnackbarVisible(false); setUndo(null); Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1)); } const refreshStatus = useCallback(async () => { // This is the *only* place status gets fetched — nothing else re-polls it. // The Android IPC bridge has been observed to silently drop an invoke() // outright: the promise never resolves *or* rejects (confirmed // separately for update_card_data — same symptom, no backend log line at // all, so the call never even reached Rust). A try/catch alone doesn't // help against that, since nothing ever throws; the race against a // per-attempt timeout below is what actually turns a hang into a // retryable failure. Without it, this ran once, hung forever, and the // app was permanently stuck on "no card data yet" every launch — even // with a fully loaded reference.db on the backend, since nothing else // ever asks it again. for (let attempt = 0; ; attempt++) { try { const s = await Promise.race([ api.getStatus(), new Promise((_, reject) => setTimeout(() => reject(new Error("get_status timed out")), 5000), ), ]); setStatus(s); return; } catch (err) { if (attempt >= 4) { console.error("getStatus failed after retries:", err); return; } await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); } } }, []); useEffect(() => { refreshStatus(); const un = listen("ingest-progress", (e) => { gotProgress.current = true; lastProgress.current = e.payload; setProgress(e.payload); }); return () => { un.then((f) => f()); }; }, [refreshStatus]); // Force a re-render every minute so the "Updated Xh ago" stamp stays fresh // over a long session without needing an actual status refetch. const [, tick] = useState(0); useEffect(() => { const interval = setInterval(() => tick((n) => n + 1), 60000); return () => clearInterval(interval); }, []); const ready = status?.reference_ready ?? false; // On Android the very first IPC call after launch has been observed taking // several seconds (likely one-time JIT/class-verification warm-up on the // native bridge, not a hang — it does complete). `!ready` was previously // used both for "we don't know yet" and "confirmed no card data", so during // that warm-up the app showed the full "Download card data" prompt — which // invited exactly what happened: a redundant download click before the // real status had even loaded. Distinguish the two explicitly. const statusLoaded = status !== null; // Mirrors `ready` for the automatic-sync callbacks, which must stay stable // across renders (re-creating them would re-arm the listeners below and fire // a fresh sync every time card data finished loading). const readyRef = useRef(ready); useEffect(() => { readyRef.current = ready; }, [ready]); // Keep the sidebar's library summary fresh: fetch once card data is ready, // then poll (cards/decks can change from any tab without a shared event bus). useEffect(() => { if (!ready) { setLibStats(null); return; } let cancelled = false; const refresh = () => api.getLibraryStats().then((s) => !cancelled && setLibStats(s)); refresh(); const interval = setInterval(refresh, 5000); return () => { cancelled = true; clearInterval(interval); }; }, [ready]); async function updateCardData() { setBusy(true); gotProgress.current = false; let stallTimer: ReturnType | undefined; try { // Android's Tauri IPC bridge has been observed to silently drop an // invoke() during a dev-mode webview reload: the promise never settles, // no ingest-progress event ever arrives, and the button was stuck // "Downloading…" forever with no way to retry (the backend never even // logged that it started). A real ingest always emits its first // progress event within milliseconds — it happens before any network // I/O — so treat "no progress after a generous window" as a dropped // call rather than hang indefinitely. const s = await Promise.race([ api.updateCardData(), new Promise((_, reject) => { stallTimer = setTimeout(() => { if (!gotProgress.current) { reject(new Error("Couldn't reach the app. Try again — if this keeps happening, restart the app.")); } }, 8000); }), ]); setStatus(s); setProgress({ stage: "done", message: `Loaded ${s.card_count.toLocaleString()} cards.`, overall_percent: 100, stages: lastProgress.current?.stages ?? [], }); setTimeout(() => setProgress((p) => (p?.stage === "done" ? null : p)), 4000); } catch (err) { const msg = String(err); if (msg === "cancelled") { setProgress({ stage: "cancelled", message: "Cancelled.", overall_percent: lastProgress.current?.overall_percent ?? 0, stages: lastProgress.current?.stages ?? [], }); setTimeout(() => setProgress((p) => (p?.stage === "cancelled" ? null : p)), 3000); } else { setProgress({ stage: "error", message: `Failed: ${msg}`, overall_percent: lastProgress.current?.overall_percent ?? 0, stages: lastProgress.current?.stages ?? [], }); } } finally { clearTimeout(stallTimer); setBusy(false); } } async function cancelUpdate() { await api.cancelUpdateCardData(); } /** Refresh the synced-folder grant state. Never throws: "no folder chosen" is * a normal state, reported in the panel rather than as an error. */ const refreshSync = useCallback(async () => { setSyncBusy(true); try { setSync(await api.syncStatus()); } catch (err) { setSyncTest({ ok: false, message: String(err) }); } finally { setSyncBusy(false); } }, []); // Once at startup: the automatic sync below needs to know whether a folder is // configured at all. useEffect(() => { if (statusLoaded) refreshSync(); }, [statusLoaded, refreshSync]); // And whenever the Info tab is opened, which is also when the user gets back // from the folder picker, and when a revoked grant would first matter. useEffect(() => { if (tab === "info") refreshSync(); }, [tab, refreshSync]); /** * Point the app at `path` and bring the whole UI into line with it. * * Switching documents changes the entire library underneath every view, so * refreshing the sync panel alone isn't enough: the path bar reads * `status.library_path`, the sidebar reads `libStats`, and each tab is * holding rows fetched from the *previous* library. Returns a short " — N * cards, M decks" suffix so the caller can confirm what actually opened. */ async function adoptDocument(path: string): Promise { setSync(await api.syncSetDocument(path)); // The path bar comes from get_status, which nothing else re-fetches. setStatus(await api.getStatus()); setDeckRefreshSignal((v) => v + 1); if (!readyRef.current) return ""; try { const stats = await api.getLibraryStats(); setLibStats(stats); return ` — ${stats.unique_cards} card${stats.unique_cards === 1 ? "" : "s"}, ${ stats.deck_count } deck${stats.deck_count === 1 ? "" : "s"}`; } catch { return ""; } } /** Choose where the library lives. Android grants a folder through the system * picker (SAF has no path to hand back); desktop picks the JSON file itself. */ async function pickLibrary() { setSyncTest(null); try { if (sync?.picker_based) { setSyncBusy(true); setSync(await api.syncPickFolder()); return; } const path = await open({ title: "Choose your library file", // Start where the library currently lives, so the dialog opens // somewhere useful rather than at an arbitrary default. defaultPath: sync?.label ?? undefined, filters: [ { name: "mtgc library", extensions: ["json"] }, { name: "All files", extensions: ["*"] }, ], }); if (typeof path !== "string") return; setSyncBusy(true); const opened = await adoptDocument(path); setSyncTest({ ok: true, message: `Opened ${path}${opened}.` }); } catch (err) { setSyncTest({ ok: false, message: String(err) }); } finally { setSyncBusy(false); } } /** Desktop: start a new library file, e.g. inside a synced folder. */ async function newLibrary() { setSyncTest(null); try { const path = await save({ title: "Save your library here (put it in a synced folder)", defaultPath: "mtgc-library.json", filters: [{ name: "mtgc library", extensions: ["json"] }], }); if (typeof path !== "string") return; setSyncBusy(true); // Pointing at a file that doesn't exist yet moves the current library // there rather than starting empty — see sync_set_document. const moved = await adoptDocument(path); setSyncTest({ ok: true, message: `Your library is now kept at ${path}${moved}.` }); } catch (err) { setSyncTest({ ok: false, message: String(err) }); } finally { setSyncBusy(false); } } async function forgetLibrary() { setSyncBusy(true); setSyncTest(null); try { setSync(await api.syncForgetDocument()); } catch (err) { setSyncTest({ ok: false, message: String(err) }); } finally { setSyncBusy(false); } } /** Convert a library.db from an older build into the current format. */ async function convertLegacy(path: string) { setSyncBusy(true); setSyncTest(null); try { const r = await api.convertLegacyLibrary(path); const n = r.report.cards_changed + r.report.decks_changed; const status = await api.syncStatus(); setSync(status); // Say where it landed — otherwise a successful conversion looks like // nothing happened. setSyncTest({ ok: true, message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`, }); if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); setDeckRefreshSignal((v) => v + 1); } catch (err) { setSyncTest({ ok: false, message: String(err) }); } finally { setSyncBusy(false); } } /** Merge the library document with local edits and publish the result. * * Shared by the Info tab's button and the automatic triggers below. Returns * the report so the caller can decide whether to say anything; throws only * what the backend threw. */ const runSync = useCallback(async () => { const report = await api.syncNow(); if (!report) return null; // Another sync had it in hand. const changed = report.changed; if (changed) { // Library and deck views hold fetched copies; a merge changes rows // underneath them, so pull them back into line rather than waiting for // the summary's 5s poll. Needs card data loaded, hence the guard. if (readyRef.current) { api.getLibraryStats().then(setLibStats).catch(() => {}); } setDeckRefreshSignal((v) => v + 1); } return { report, changed }; }, []); // Automatic sync. Runs once the folder is usable, then on every trip to or // from the background: leaving publishes what changed here, returning picks // up what changed elsewhere. Keyed on `granted` alone so merely re-checking // the folder's status doesn't re-arm the listeners and resync. const syncGranted = sync?.granted ?? false; useEffect(() => { if (!syncGranted) return; let cancelled = false; // Failures here are deliberately silent: the folder can be temporarily // unavailable (drive unmounted, grant revoked), and nagging on every app // switch would be worse than the Info tab reporting it on demand. Nothing // is lost by a skipped run — the merge base only advances to a document we // actually read, so a lost publish is simply re-detected next time. const run = () => { if (cancelled) return; runSync().catch(() => {}); }; run(); document.addEventListener("visibilitychange", run); // Visibility changes alone would strand a desktop session that simply stays // open: edits made after launch would never reach the other devices. Poll // while visible so an always-open window still publishes. Skipped while // hidden — the transition back handles that case. const timer = setInterval(() => { if (!document.hidden) run(); }, AUTO_SYNC_MS); return () => { cancelled = true; clearInterval(timer); document.removeEventListener("visibilitychange", run); }; }, [syncGranted, runSync]); /** The Info tab's explicit "Sync now" — same work, but reports the outcome. */ async function syncNow() { setSyncBusy(true); setSyncTest(null); try { const result = await runSync(); if (!result) { setSyncTest({ ok: true, message: "A sync was already running." }); return; } const { report, changed } = result; const parts: string[] = []; if (changed) { const r = report.report; const bits = [ r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`, r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`, r.decks_removed && `${r.decks_removed} removed`, ].filter(Boolean); parts.push(`Merged ${bits.join(", ")}.`); } else { parts.push(report.published ? "Published your changes." : "Already up to date."); } if (report.absorbed > 0) { parts.push( `Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`, ); } // Conflicts are the one thing worth spelling out — the user may want to // correct a value that lost. if (report.report.conflicts.length > 0) { parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`); } setSyncTest({ ok: true, message: parts.join(" ") }); } catch (err) { setSyncTest({ ok: false, message: String(err) }); } finally { setSyncBusy(false); } } /** Adopt a reference.db built elsewhere (e.g. copied from a desktop install) * instead of streaming ~740MB to rebuild it on this device. */ async function importReferenceDb() { const path = await open({ title: "Import a card database (reference.db)", filters: [{ name: "Card database", extensions: ["db", "sqlite"] }], }); if (typeof path !== "string") return; try { setStatus(await api.importReferenceDb(path)); setProgress(null); } catch (err) { setProgress({ stage: "error", message: `Couldn't import that card database: ${String(err)}`, overall_percent: 0, stages: lastProgress.current?.stages ?? [], }); } } return (
{status?.library_path ?? ""} {/* Desktop-only: Android grants a folder through the system picker instead of naming a path, so these live in the Info tab there. */} {status && !status.is_mobile && ( <> )} {/* On mobile this lives in the bottom nav's Info tab instead — see below — rather than crowding the topbar. */} {status && !status.is_mobile && ( )}
{progress && (
{progress.message} {busy && ( )} {!busy && (progress.stage === "done" || progress.stage === "error" || progress.stage === "cancelled") && ( setProgress(null)} /> )}
{progress.stages.map((s, i) => (
{s.label}
))}
)}
{!statusLoaded ? (

Loading…

) : !ready ? (

Welcome to mtgc

No card database yet. Download card data fetches the latest cards from Scryfall (a few hundred MB {status?.is_mobile ? " — slow over mobile data" : ", about a minute"}). The reference data stays on this device — only your library file is meant to be synced.

{status?.is_mobile && (

Already have it on your computer? Copying that{" "} reference.db over is much faster — it's about 73MB versus the ~740MB the download has to stream.

)}
) : tab === "add" ? ( ) : /* Keyed on the library document: switching to a different one replaces every card, deck and id these views are holding, so they're remounted rather than left to reconcile state that belongs to a library that's no longer open. Stable across ordinary status refreshes, so it costs nothing otherwise. */ tab === "library" ? ( ) : tab === "search" ? ( ) : tab === "decks" ? ( ) : tab === "news" ? ( // Library-independent, so unlike the others it isn't keyed on the // document — switching libraries doesn't change the news. ) : ( // The per-install status page: card data, library sync, and the // collection summary. On mobile it's the only home for the // card-data buttons (no topbar room) and the summary (the nav // rail it normally sits in is replaced by the bottom nav); // on desktop those also appear in the topbar and rail, but sync // lives only here.

Card database

{status?.library_path}

Your library

{sync === null ? (
Checking…
) : ( <>
Your collection is kept in a single file. Put it in a folder you sync and every device stays in step — edits made in two places are combined, not overwritten.
{sync.label && (
{sync.label}
)} {sync.chosen && !sync.granted && (
✕{" "} {sync.picker_based ? "Access to that folder was lost — choose it again." : "That folder isn't there right now — is the drive mounted?"}
)} {syncTest && (
{syncTest.ok ? "✓ " : "✕ "} {syncTest.message}
)}
{sync.granted && ( )} {sync.chosen && ( )}
{/* Only shown while an old library.db is still waiting; the offer disappears for good once it's been converted. */} {sync.legacy_library && ( <>
An older library from a previous version is still on this device. Convert it to bring your collection across.
{sync.legacy_library}
)} )}
)}
{/* Android-customary bottom tab bar — replaces the rail above on narrow viewports (see the ≤720px CSS breakpoint), rather than the rail collapsing to an icon strip in place. Hidden while the initial status fetch is still pending: there's nothing to navigate to yet, and showing it makes the loading screen look further along than it is. Carries an extra "Info" tab beyond `NAV` — card-database management, which lives in the topbar on desktop instead (see the button below). */} {statusLoaded && ( )}
{undo && snackbarVisible && ( )} ); }