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 } 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"; type Tab = "add" | "library" | "search" | "decks" | "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( <> , ), }, ]; /** 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; 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); 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; // 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(); } async function switchLibrary(path: string) { try { setStatus(await api.setLibraryPath(path)); } catch (err) { setProgress({ stage: "error", message: `Couldn't open that library: ${String(err)}`, overall_percent: 0, stages: lastProgress.current?.stages ?? [], }); } } /** 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 ?? [], }); } } async function openLibrary() { const path = await open({ title: "Open an existing library.db", filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }], }); if (typeof path === "string") await switchLibrary(path); } async function newLibrary() { const path = await save({ title: "Create a new library.db (put it in a synced folder)", defaultPath: "library.db", filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }], }); if (typeof path === "string") await switchLibrary(path); } return (
{status?.library_path ?? ""} {/* Choosing where library.db lives is desktop-only: on Android the live db must stay in app-private storage (SQLite can't open a content:// URI), so these would only ever fail 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" ? ( ) : tab === "library" ? ( ) : tab === "search" ? ( ) : tab === "decks" ? ( ) : ( // Mobile-only tab (see INFO_TAB) — the desktop equivalent is // split between the topbar's Update card data button (hidden // here on mobile) and the nav rail's footer summary (which // has nowhere to live once the rail collapses to the bottom // nav), so both land here together.

Card database

{status?.library_path}

)}
{/* 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 && ( )} ); }