import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { api, IngestProgress, IngestStageInfo, LibraryInfo, LibraryStats, setOpenLibrary, Status, SyncStatus, } from "./api"; import { ColorDistributionBar, formatRelativeTime, HoverPreviewProvider, IconButton, OpenDeckProvider, Icons, StatTile, UndoBar, usePersistedState, } from "./components"; import { CommandPalette } from "./CommandPalette"; import { LibrariesPanel, LibrarySwitcher } from "./Libraries"; import { SyncIndicator, SyncPanel } from "./SyncPanel"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { CardSearch } from "./views/CardSearch"; import { DeckView } from "./views/DeckView"; import { GameView } from "./views/GameView"; import { NewsView } from "./views/NewsView"; type Tab = "add" | "library" | "search" | "decks" | "game" | "news" | "info"; const svg = (children: ReactNode, size = 20) => ( {children} ); const ICON_OPEN_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: "game", label: "Game", 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( <> , ), }; /** "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 load the * server, short enough that a second device sees edits without being * restarted. Launch, reconnecting, 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"); // A deck a chip elsewhere asked to open; DeckView picks it up and clears it. const [openDeckId, setOpenDeckId] = useState(null); // Deck chips only carry the deck's name, which is unique per library. const openDeck = useCallback( async (name: string) => { const deck = (await api.listDecks()).find((d) => d.name === name); if (!deck) return; setOpenDeckId(deck.id); setTab("decks"); }, [setTab], ); const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); // Library sync state: signed in or not, and how the last sync went. Fetched // at startup, after every sync, and whenever the Info tab opens. const [sync, setSync] = useState(null); const [libraries, setLibraries] = useState([]); // A sync is in flight, for the top-bar indicator. const [syncing, setSyncing] = useState(false); 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; // Card data built by an older version lacks columns this one reads, so // some features quietly stay off until it's updated. "Not now" is // remembered against exactly what's missing: the notice comes back once a // later version adds something more. const missingColumns = status?.reference_missing_columns ?? []; const outdatedKey = missingColumns.join(","); const [dismissedOutdated, setDismissedOutdated] = usePersistedState("card-data-outdated-dismissed", ""); const showOutdated = ready && missingColumns.length > 0 && !progress && dismissedOutdated !== outdatedKey; // 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 sync state. Never throws: being offline or signed out is a * normal state, reported in the panel rather than as an error. */ const refreshSync = useCallback(async () => { try { setSync(await api.syncStatus()); } catch (err) { console.error("syncStatus failed:", err); } }, []); // Once at startup: the automatic sync below needs to know whether this // device is signed in at all. // // Gated on `statusLoaded`: `getStatus` retries until the backend is ready, // so waiting on it guarantees setup() has registered the state these // commands need. useEffect(() => { if (statusLoaded) refreshSync(); }, [statusLoaded, refreshSync]); // And whenever the Info tab is opened, so "synced 5m ago" is current there. useEffect(() => { if (tab === "info" && statusLoaded) refreshSync(); }, [tab, statusLoaded, refreshSync]); /** * Bring every view into line after the library may have changed wholesale: * signing in or out, opening another library, adopting the server's copy, * importing a file. The top bar's label comes from get_status, which nothing * else re-fetches; the sidebar reads `libStats`; each tab holds rows it * fetched earlier; and signing in can bring libraries from other devices. */ const refreshEverything = useCallback(() => { api.getStatus().then(setStatus).catch(() => {}); api.listLibraries().then(setLibraries).catch(() => {}); if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); setDeckRefreshSignal((v) => v + 1); }, []); const refreshLibraries = useCallback(() => { api.listLibraries().then(setLibraries).catch(() => {}); }, []); useEffect(() => { if (statusLoaded) refreshLibraries(); }, [statusLoaded, refreshLibraries]); /** Another library was opened, or the list changed: the open library may * be a different one now, so everything refetches. */ const librariesChanged = useCallback( (libs: LibraryInfo[]) => { setLibraries(libs); refreshEverything(); api.syncStatus().then(setSync).catch(() => {}); }, [refreshEverything], ); /** Merge with the server and publish local edits. * * Shared by the Info tab's button and the automatic triggers below. Resolves * with the report (null when nothing ran); throws only what the backend * threw. Either way the sync state is re-read, so the indicator follows. */ const runSync = useCallback(async () => { setSyncing(true); try { const report = await api.syncNow(); if (report?.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); } if (report?.libraries_changed) { // Downloaded, renamed or deleted elsewhere — the open one's name in // the top bar included. refreshLibraries(); api.getStatus().then(setStatus).catch(() => {}); } return report; } finally { setSyncing(false); api.syncStatus().then(setSync).catch(() => {}); } }, [refreshLibraries]); // Automatic sync, whenever signed in (and not waiting on the first-sync // choice): once now, on every trip to or from the background — leaving // publishes what changed here, returning picks up what changed elsewhere — // and on reconnecting. Keyed on that flag alone so re-reading the status // after each sync doesn't re-arm the listeners and sync again. const syncActive = (sync?.signed_in ?? false) && !(sync?.needs_choice ?? false); useEffect(() => { if (!syncActive) return; let cancelled = false; // Failures surface through the indicator rather than as errors: being // offline is routine, and nothing is lost by a skipped run — edits stay // in the working copy, and the merge base only ever records what the // server confirmed, so the next run picks up exactly where this left off. const run = () => { if (cancelled) return; runSync().catch(() => {}); }; run(); document.addEventListener("visibilitychange", run); window.addEventListener("online", 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); window.removeEventListener("online", run); }; }, [syncActive, runSync]); /** 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 ?? [], }); } } // Before any view renders: they read per-library settings (`libraryKey`) // as they mount. if (status) setOpenLibrary(status.library.id); return (
{status ? ( setTab("info")} /> ) : ( )} setTab("info")} /> {/* 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}
))}
)} {showOutdated && (
Your card data was built by an older version of mtgc, so some newer features stay off until you update it. {/* A full download is heavy on a phone; copying a fresh reference.db over from a desktop is the cheaper route. */} {status?.is_mobile && ( )}
)}
{!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 is 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 open library and its sync account: switching either can replace 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" ? ( setOpenDeckId(null)} /> ) : tab === "game" ? ( // Like News, independent of the library: the game in progress // is this device's scratch state, not synced data. ) : tab === "news" ? ( // Library-independent, so unlike the others it isn't keyed on the // account — 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



{sync === null ? ( <>

Your library

Checking…
) : ( )}
)}
{/* 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 && ( )} runSync().catch(() => {})} undo={undo} onRunUndo={runUndo} /> ); }