Files
mtgc/src/App.tsx
Garrett Mills b96931c7b8 Deck chips open their deck
Deck-name chips in the Library, Card Search and the card preview modal are
now buttons (DeckChip) that open that deck in the Decks tab — on mobile,
straight to its building screen. App provides the open-deck callback via
context and hands the deck id to DeckView.

Closes #19
2026-10-23 23:10:40 -05:00

878 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const ICON_OPEN_LIBRARY = svg(
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
16,
);
const ICON_DOWNLOAD = svg(
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</>,
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 (
<svg viewBox="0 0 200 200" width={size} height={size}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--accent-hover)" />
<stop offset="1" stopColor="var(--accent-active)" />
</linearGradient>
</defs>
<g fill={`url(#${gradientId})`}>
<path d="M21,137 L179,137 L159,176 Q100,193 41,176 Z" />
<rect x="21" y="133" width="158" height="6" rx="3" />
<rect x="95" y="7" width="10" height="127" />
<path d="M100,32 C73,40 56,65 59,94 C60,103 71,110 87,113 L100,113 Z" />
<path d="M100,14 C147,26 168,73 160,114 C158,127 140,137 119,140 L100,140 Z" />
<path d="M100,7 L126,13 L109,18 L126,23 L100,30 Z" />
</g>
</svg>
);
}
const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
{ id: "add", label: "Add Cards", icon: svg(<path d="M12 5v14M5 12h14" />) },
{
id: "library",
label: "Library",
icon: svg(
<>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</>,
),
},
{
id: "search",
label: "Card Search",
icon: svg(
<>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>,
),
},
{
id: "decks",
label: "Decks",
icon: svg(
<>
<path d="M12 2.5l8.5 4.5-8.5 4.5L3.5 7z" />
<path d="M3.5 12l8.5 4.5 8.5-4.5" />
<path d="M3.5 16.5l8.5 4.5 8.5-4.5" />
</>,
),
},
{
id: "game",
label: "Game",
icon: svg(
<>
<path d="M12 2.5l8.25 4.75v9.5L12 21.5l-8.25-4.75v-9.5z" />
<path d="M12 7.5l4 6.5H8z" />
</>,
),
},
{
id: "news",
label: "News",
icon: svg(
<>
<path d="M4 5h11a1 1 0 0 1 1 1v13H5a1 1 0 0 1-1-1z" />
<path d="M16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-2 0V9" />
<line x1="7" y1="9" x2="13" y2="9" />
<line x1="7" y1="13" x2="13" y2="13" />
</>,
),
},
];
/** 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(
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</>,
),
};
/** "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 (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
);
}
return (
<div className="lib-stats">
<div className="lib-stats-head">
<span className="status-dot on" />
<span className="lib-stats-title">Your library</span>
</div>
{!libStats ? (
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">
<div>{status?.card_count.toLocaleString()} cards in database</div>
{status?.reference_updated_at != null && (
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
Updated {formatRelativeTime(status.reference_updated_at)}
</div>
)}
</div>
</div>
);
}
/** 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<Status | null>(null);
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
// A deck a chip elsewhere asked to open; DeckView picks it up and clears it.
const [openDeckId, setOpenDeckId] = useState<number | null>(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<IngestProgress | null>(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<SyncStatus | null>(null);
const [libraries, setLibraries] = useState<LibraryInfo[]>([]);
// A sync is in flight, for the top-bar indicator.
const [syncing, setSyncing] = useState(false);
const lastProgress = useRef<IngestProgress | null>(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<void> } | null>(
null,
);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const snackbarTimer = useRef<ReturnType<typeof setTimeout> | 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<void>) {
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<never>((_, 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<IngestProgress>("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<typeof setTimeout> | 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<never>((_, 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 (
<OpenDeckProvider value={openDeck}>
<HoverPreviewProvider>
<div className="app">
<nav className="nav-rail">
<div className="brand">
<span className="brand-mark">
<ShipLogo size={20} gradientId="brand-ship-grad" />
</span>
<span className="brand-text">mtgc</span>
</div>
<div className="nav-items">
{/* Info carries library sync, which desktop needs too — it's no
longer the mobile-only card-data page it started as. */}
{[...NAV, INFO_TAB].map((n) => (
<button
key={n.id}
className={`nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
title={n.label}
>
<span className="nav-icon">{n.icon}</span>
<span className="nav-label">{n.label}</span>
</button>
))}
</div>
<div className="nav-foot">
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
</nav>
<div className="main">
<header className="topbar">
{status ? (
<LibrarySwitcher
label={status.library_label}
libraries={libraries}
signedIn={sync?.signed_in ?? false}
onChanged={librariesChanged}
onManage={() => setTab("info")}
/>
) : (
<span className="path" />
)}
<SyncIndicator sync={sync} syncing={syncing} onClick={() => 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 && (
<button onClick={updateCardData} disabled={busy} className="primary">
{ICON_DOWNLOAD}
{ready ? "Update card data" : "Download card data"}
</button>
)}
</header>
{progress && (
<div className={`ingest-bar ingest-bar-${progress.stage}`}>
<div className="ingest-bar-head">
<span className="ingest-bar-message">{progress.message}</span>
<span className="spacer" />
{busy && (
<button className="ingest-bar-cancel" onClick={cancelUpdate}>
Cancel
</button>
)}
{!busy &&
(progress.stage === "done" ||
progress.stage === "error" ||
progress.stage === "cancelled") && (
<IconButton icon={Icons.close} label="Dismiss" onClick={() => setProgress(null)} />
)}
</div>
<div className="ingest-segments">
{progress.stages.map((s, i) => (
<div
key={s.id}
className={`ingest-segment ${s.id === progress.stage ? "active" : ""}`}
style={{ flexGrow: s.weight, flexBasis: 0 }}
>
<div className="ingest-segment-track">
<div
className="ingest-segment-fill"
style={{ width: `${segmentFill(progress.stages, i, progress.overall_percent)}%` }}
/>
</div>
<span className="ingest-segment-label">{s.label}</span>
</div>
))}
</div>
</div>
)}
{showOutdated && (
<div className="outdated-bar" role="status">
<span className="outdated-bar-message">
Your card data was built by an older version of mtgc, so some newer features stay off until you
update it.
</span>
<span className="outdated-bar-actions">
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
Update card data
</button>
{/* A full download is heavy on a phone; copying a fresh
reference.db over from a desktop is the cheaper route. */}
{status?.is_mobile && (
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
)}
<button onClick={() => setDismissedOutdated(outdatedKey)}>Not now</button>
</span>
</div>
)}
<div className="content">
{!statusLoaded ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
</div>
<p className="hint">Loading…</p>
</div>
) : !ready ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
</div>
<h2>Welcome to mtgc</h2>
<p className="hint">
No card database yet. <strong>Download card data</strong> 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.
</p>
{status?.is_mobile && (
<p className="hint">
Already have it on your computer? Copying that{" "}
<strong>reference.db</strong> over is much faster — it's about 73MB
versus the ~740MB the download has to stream.
</p>
)}
<button className="primary big" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Download card data"}
</button>
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
</div>
) : tab === "add" ? (
<CardEntry key={status?.library_label} />
) : /* 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" ? (
<LibraryBrowser key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "decks" ? (
<DeckView
key={status?.library_label}
refreshSignal={deckRefreshSignal}
openDeckId={openDeckId}
onOpenedDeck={() => setOpenDeckId(null)}
/>
) : tab === "game" ? (
// Like News, independent of the library: the game in progress
// is this device's scratch state, not synced data.
<GameView />
) : tab === "news" ? (
// Library-independent, so unlike the others it isn't keyed on the
// account — switching libraries doesn't change the news.
<NewsView />
) : (
// 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.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Update card data"}
</button>
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrariesPanel
libraries={libraries}
signedIn={sync?.signed_in ?? false}
onChanged={librariesChanged}
/>
<hr className="deck-section-divider" style={{ margin: 0 }} />
{sync === null ? (
<>
<h2 style={{ margin: 0 }}>Your library</h2>
<div className="hint">Checking…</div>
</>
) : (
<SyncPanel
sync={sync}
setSync={setSync}
syncing={syncing}
runSync={runSync}
onLibraryReplaced={refreshEverything}
/>
)}
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
)}
</div>
</div>
{/* 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 && (
<nav className="bottom-nav">
{[...NAV, INFO_TAB].map((n) => (
<button
key={n.id}
className={`bottom-nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
>
<span className="nav-icon">{n.icon}</span>
<span>{n.label}</span>
</button>
))}
</nav>
)}
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
)}
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onSyncNow={() => runSync().catch(() => {})}
undo={undo}
onRunUndo={runUndo}
/>
</HoverPreviewProvider>
</OpenDeckProvider>
);
}