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 { HoverPreviewProvider, IconButton, Icons, usePersistedState } from "./components"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { DeckView } from "./views/DeckView"; type Tab = "add" | "library" | "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: "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; } 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); 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 (
{status?.library_path ?? ""}
{progress && (
{progress.message} {busy && ( )} {!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.

) : tab === "add" ? ( ) : tab === "library" ? ( ) : ( )}
); }