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"; 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( <> >, ), }, ]; /** 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`; } /** 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); // 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 () => { setStatus(await api.getStatus()); }, []); useEffect(() => { refreshStatus(); const un = listen("ingest-progress", (e) => { 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; // 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); try { const s = await api.updateCardData(); 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 { 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 ?? [], }); } } 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 ( mtgc {NAV.map((n) => ( setTab(n.id)} title={n.label} > {n.icon} {n.label} ))} {!ready ? ( No card data ) : ( Your library {!libStats ? ( Loading… ) : ( <> > )} {status?.card_count.toLocaleString()} cards in database {status?.reference_updated_at != null && ( Updated {formatRelativeTime(status.reference_updated_at)} )} )} {status?.library_path ?? ""} {ICON_OPEN_LIBRARY} Open Library {ICON_NEW_LIBRARY} New Library {ICON_DOWNLOAD} {ready ? "Update card data" : "Download card data"} {progress && ( {progress.message} {busy && ( Cancel )} {!busy && (progress.stage === "done" || progress.stage === "error" || progress.stage === "cancelled") && ( setProgress(null)} /> )} {progress.stages.map((s, i) => ( {s.label} ))} )} {!ready ? ( Welcome to mtgc No card database yet. Click Download card data to fetch the latest cards from Scryfall (a few hundred MB, about a minute). The reference data stays on this device — only your library file is meant to be synced. {ICON_DOWNLOAD} {busy ? "Downloading…" : "Download card data"} ) : tab === "add" ? ( ) : tab === "library" ? ( ) : tab === "search" ? ( ) : ( )} {undo && snackbarVisible && ( )} ); }
No card database yet. Click Download card data to fetch the latest cards from Scryfall (a few hundred MB, about a minute). The reference data stays on this device — only your library file is meant to be synced.