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
878 lines
34 KiB
TypeScript
878 lines
34 KiB
TypeScript
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>
|
||
);
|
||
}
|