Android grants a *folder* through SAF, not a file, and the document name was hardcoded to "library.json". A library named anything else was therefore never found: the phone created an empty second library beside it and the two devices silently never synced, while appearing to work. The Info tab now lists the .json files in the granted folder and lets you choose one. Picking an existing file adopts it — the working copy is dropped and rebuilt from it, matching what desktop does when opening a document — while a name that isn't there yet keeps the current library and publishes it, matching New Library. The name is validated as a plain filename, since a path would escape the granted folder that the SAF grant exists to bound. Two bugs found while testing this on-device: load_config decided whether a config was the pre-document format by testing whether document_path or legacy_library_path were set. An Android install has neither — its folder is a grant, not a path, and there is no legacy library — so a valid config's document_name was discarded on every launch and the chosen library file silently reverted to the default. It no longer infers the format from which fields happen to be populated; every field defaults, and the legacy library_path is picked up as a separate additive step. Covered by tests, including one built from the exact Android config shape, because a setting that quietly resets itself is not something a careful read catches. The Info tab's sync refresh fired at mount when the app relaunched onto that tab — the active tab is persisted — which is before setup() calls manage(), producing "state not managed for field db". Gated on statusLoaded like the startup refresh already was; get_status retries until the backend is ready, so waiting on it is what guarantees the state exists.
1069 lines
42 KiB
TypeScript
1069 lines
42 KiB
TypeScript
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, SyncStatus } 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";
|
||
import { NewsView } from "./views/NewsView";
|
||
|
||
type Tab = "add" | "library" | "search" | "decks" | "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_NEW_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" />
|
||
<line x1="12" y1="11" x2="12" y2="17" />
|
||
<line x1="9" y1="14" x2="15" y2="14" />
|
||
</>,
|
||
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: "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" />
|
||
</>,
|
||
),
|
||
};
|
||
|
||
/** 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 (
|
||
<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 churn the
|
||
* synced folder, short enough that a second device sees edits without being
|
||
* restarted. Launch 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");
|
||
const [progress, setProgress] = useState<IngestProgress | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
// Synced-folder grant for library sync. Only fetched when the Info tab is
|
||
// open: the grant can be revoked from system settings while the app is
|
||
// backgrounded, so a value cached at startup would be stale exactly when the
|
||
// user comes back to check it.
|
||
const [sync, setSync] = useState<SyncStatus | null>(null);
|
||
const [syncBusy, setSyncBusy] = useState(false);
|
||
// Result of the last round-trip self-test, shown until the next action.
|
||
const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null);
|
||
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;
|
||
// 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 synced-folder grant state. Never throws: "no folder chosen" is
|
||
* a normal state, reported in the panel rather than as an error. */
|
||
const refreshSync = useCallback(async () => {
|
||
setSyncBusy(true);
|
||
try {
|
||
setSync(await api.syncStatus());
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}, []);
|
||
|
||
// Once at startup: the automatic sync below needs to know whether a folder is
|
||
// configured at all.
|
||
useEffect(() => {
|
||
if (statusLoaded) refreshSync();
|
||
}, [statusLoaded, refreshSync]);
|
||
|
||
// And whenever the Info tab is opened, which is also when the user gets back
|
||
// from the folder picker, and when a revoked grant would first matter.
|
||
//
|
||
// Gated on `statusLoaded` as well as the tab: the active tab is persisted, so
|
||
// relaunching while on Info makes this fire at mount — before setup() has
|
||
// called manage(), which fails with "state not managed for field db".
|
||
// `getStatus` retries until the backend is ready, so waiting on it is what
|
||
// guarantees the state exists.
|
||
useEffect(() => {
|
||
if (tab === "info" && statusLoaded) refreshSync();
|
||
}, [tab, statusLoaded, refreshSync]);
|
||
|
||
|
||
/**
|
||
* Point the app at `path` and bring the whole UI into line with it.
|
||
*
|
||
* Switching documents changes the entire library underneath every view, so
|
||
* refreshing the sync panel alone isn't enough: the path bar reads
|
||
* `status.library_path`, the sidebar reads `libStats`, and each tab is
|
||
* holding rows fetched from the *previous* library. Returns a short " — N
|
||
* cards, M decks" suffix so the caller can confirm what actually opened.
|
||
*/
|
||
async function adoptDocument(path: string): Promise<string> {
|
||
setSync(await api.syncSetDocument(path));
|
||
// The path bar comes from get_status, which nothing else re-fetches.
|
||
setStatus(await api.getStatus());
|
||
setDeckRefreshSignal((v) => v + 1);
|
||
if (!readyRef.current) return "";
|
||
try {
|
||
const stats = await api.getLibraryStats();
|
||
setLibStats(stats);
|
||
return ` — ${stats.unique_cards} card${stats.unique_cards === 1 ? "" : "s"}, ${
|
||
stats.deck_count
|
||
} deck${stats.deck_count === 1 ? "" : "s"}`;
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/** Choose where the library lives. Android grants a folder through the system
|
||
* picker (SAF has no path to hand back); desktop picks the JSON file itself. */
|
||
async function pickLibrary() {
|
||
setSyncTest(null);
|
||
try {
|
||
if (sync?.picker_based) {
|
||
setSyncBusy(true);
|
||
setSync(await api.syncPickFolder());
|
||
return;
|
||
}
|
||
const path = await open({
|
||
title: "Choose your library file",
|
||
// Start where the library currently lives, so the dialog opens
|
||
// somewhere useful rather than at an arbitrary default.
|
||
defaultPath: sync?.label ?? undefined,
|
||
filters: [
|
||
{ name: "mtgc library", extensions: ["json"] },
|
||
{ name: "All files", extensions: ["*"] },
|
||
],
|
||
});
|
||
if (typeof path !== "string") return;
|
||
setSyncBusy(true);
|
||
const opened = await adoptDocument(path);
|
||
setSyncTest({ ok: true, message: `Opened ${path}${opened}.` });
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}
|
||
|
||
/** Desktop: start a new library file, e.g. inside a synced folder. */
|
||
async function newLibrary() {
|
||
setSyncTest(null);
|
||
try {
|
||
const path = await save({
|
||
title: "Save your library here (put it in a synced folder)",
|
||
defaultPath: "mtgc-library.json",
|
||
filters: [{ name: "mtgc library", extensions: ["json"] }],
|
||
});
|
||
if (typeof path !== "string") return;
|
||
setSyncBusy(true);
|
||
// Pointing at a file that doesn't exist yet moves the current library
|
||
// there rather than starting empty — see sync_set_document.
|
||
const moved = await adoptDocument(path);
|
||
setSyncTest({ ok: true, message: `Your library is now kept at ${path}${moved}.` });
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}
|
||
|
||
/** Android's second step: which file in the granted folder is the library.
|
||
* Loaded lazily, since it costs a SAF round trip. */
|
||
const [docChoices, setDocChoices] = useState<string[] | null>(null);
|
||
useEffect(() => {
|
||
if (tab !== "info" || !sync?.picker_based || !sync.granted) return;
|
||
api.syncListDocuments().then(setDocChoices).catch(() => setDocChoices([]));
|
||
}, [tab, sync?.picker_based, sync?.granted, sync?.label]);
|
||
|
||
async function chooseDocumentName(name: string) {
|
||
setSyncBusy(true);
|
||
setSyncTest(null);
|
||
try {
|
||
setSync(await api.syncSetDocumentName(name));
|
||
setStatus(await api.getStatus());
|
||
setDeckRefreshSignal((v) => v + 1);
|
||
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
||
setSyncTest({ ok: true, message: `Using ${name}.` });
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}
|
||
|
||
async function forgetLibrary() {
|
||
setSyncBusy(true);
|
||
setSyncTest(null);
|
||
try {
|
||
setSync(await api.syncForgetDocument());
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}
|
||
|
||
/** Convert a library.db from an older build into the current format. */
|
||
async function convertLegacy(path: string) {
|
||
setSyncBusy(true);
|
||
setSyncTest(null);
|
||
try {
|
||
const r = await api.convertLegacyLibrary(path);
|
||
const n = r.report.cards_changed + r.report.decks_changed;
|
||
const status = await api.syncStatus();
|
||
setSync(status);
|
||
// Say where it landed — otherwise a successful conversion looks like
|
||
// nothing happened.
|
||
setSyncTest({
|
||
ok: true,
|
||
message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`,
|
||
});
|
||
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
||
setDeckRefreshSignal((v) => v + 1);
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}
|
||
|
||
/** Merge the library document with local edits and publish the result.
|
||
*
|
||
* Shared by the Info tab's button and the automatic triggers below. Returns
|
||
* the report so the caller can decide whether to say anything; throws only
|
||
* what the backend threw. */
|
||
const runSync = useCallback(async () => {
|
||
const report = await api.syncNow();
|
||
if (!report) return null; // Another sync had it in hand.
|
||
const changed = report.changed;
|
||
if (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);
|
||
}
|
||
return { report, changed };
|
||
}, []);
|
||
|
||
// Automatic sync. Runs once the folder is usable, then on every trip to or
|
||
// from the background: leaving publishes what changed here, returning picks
|
||
// up what changed elsewhere. Keyed on `granted` alone so merely re-checking
|
||
// the folder's status doesn't re-arm the listeners and resync.
|
||
const syncGranted = sync?.granted ?? false;
|
||
useEffect(() => {
|
||
if (!syncGranted) return;
|
||
let cancelled = false;
|
||
|
||
// Failures here are deliberately silent: the folder can be temporarily
|
||
// unavailable (drive unmounted, grant revoked), and nagging on every app
|
||
// switch would be worse than the Info tab reporting it on demand. Nothing
|
||
// is lost by a skipped run — the merge base only advances to a document we
|
||
// actually read, so a lost publish is simply re-detected next time.
|
||
const run = () => {
|
||
if (cancelled) return;
|
||
runSync().catch(() => {});
|
||
};
|
||
|
||
run();
|
||
document.addEventListener("visibilitychange", 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);
|
||
};
|
||
}, [syncGranted, runSync]);
|
||
|
||
/** The Info tab's explicit "Sync now" — same work, but reports the outcome. */
|
||
async function syncNow() {
|
||
setSyncBusy(true);
|
||
setSyncTest(null);
|
||
try {
|
||
const result = await runSync();
|
||
if (!result) {
|
||
setSyncTest({ ok: true, message: "A sync was already running." });
|
||
return;
|
||
}
|
||
const { report, changed } = result;
|
||
const parts: string[] = [];
|
||
if (changed) {
|
||
const r = report.report;
|
||
const bits = [
|
||
r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`,
|
||
r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`,
|
||
r.decks_removed && `${r.decks_removed} removed`,
|
||
].filter(Boolean);
|
||
parts.push(`Merged ${bits.join(", ")}.`);
|
||
} else {
|
||
parts.push(report.published ? "Published your changes." : "Already up to date.");
|
||
}
|
||
if (report.absorbed > 0) {
|
||
parts.push(
|
||
`Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`,
|
||
);
|
||
}
|
||
// Conflicts are the one thing worth spelling out — the user may want to
|
||
// correct a value that lost.
|
||
if (report.report.conflicts.length > 0) {
|
||
parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`);
|
||
}
|
||
setSyncTest({ ok: true, message: parts.join(" ") });
|
||
} catch (err) {
|
||
setSyncTest({ ok: false, message: String(err) });
|
||
} finally {
|
||
setSyncBusy(false);
|
||
}
|
||
}
|
||
|
||
/** 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 ?? [],
|
||
});
|
||
}
|
||
}
|
||
|
||
return (
|
||
<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">
|
||
<span className="path" title={status?.library_path ?? ""}>
|
||
{status?.library_path ?? ""}
|
||
</span>
|
||
{/* Desktop-only: Android grants a folder through the system picker
|
||
instead of naming a path, so these live in the Info tab there. */}
|
||
{status && !status.is_mobile && (
|
||
<>
|
||
<button onClick={pickLibrary} disabled={busy || syncBusy}>
|
||
{ICON_OPEN_LIBRARY}
|
||
Open Library
|
||
</button>
|
||
<button onClick={newLibrary} disabled={busy || syncBusy}>
|
||
{ICON_NEW_LIBRARY}
|
||
New Library
|
||
</button>
|
||
</>
|
||
)}
|
||
{/* 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>
|
||
)}
|
||
|
||
<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 file is meant
|
||
to be 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 />
|
||
) : /* Keyed on the library document: switching to a different one
|
||
replaces 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_path} onOfferUndo={offerUndo} />
|
||
) : tab === "search" ? (
|
||
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
|
||
) : tab === "decks" ? (
|
||
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
|
||
) : tab === "news" ? (
|
||
// Library-independent, so unlike the others it isn't keyed on the
|
||
// document — 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="hint" style={{ wordBreak: "break-all" }}>
|
||
{status?.library_path}
|
||
</div>
|
||
<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 }} />
|
||
<h2 style={{ margin: 0 }}>Your library</h2>
|
||
{sync === null ? (
|
||
<div className="hint">Checking…</div>
|
||
) : (
|
||
<>
|
||
<div className="hint">
|
||
Your collection is kept in a single file. Put it in a folder you sync and
|
||
every device stays in step — edits made in two places are combined, not
|
||
overwritten.
|
||
</div>
|
||
{sync.label && (
|
||
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
||
{sync.label}
|
||
</div>
|
||
)}
|
||
{sync.chosen && !sync.granted && (
|
||
<div className="sync-bad">
|
||
✕{" "}
|
||
{sync.picker_based
|
||
? "Access to that folder was lost — choose it again."
|
||
: "That folder isn't there right now — is the drive mounted?"}
|
||
</div>
|
||
)}
|
||
{syncTest && (
|
||
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
|
||
{syncTest.ok ? "✓ " : "✕ "}
|
||
{syncTest.message}
|
||
</div>
|
||
)}
|
||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||
{sync.granted && (
|
||
<button className="primary" onClick={syncNow} disabled={syncBusy}>
|
||
{syncBusy ? "Syncing…" : "Sync now"}
|
||
</button>
|
||
)}
|
||
<button
|
||
className={sync.granted ? "" : "primary"}
|
||
onClick={pickLibrary}
|
||
disabled={syncBusy}
|
||
>
|
||
{sync.chosen
|
||
? sync.picker_based
|
||
? "Change folder…"
|
||
: "Change file…"
|
||
: sync.picker_based
|
||
? "Choose folder…"
|
||
: "Choose file…"}
|
||
</button>
|
||
{sync.chosen && (
|
||
<button onClick={forgetLibrary} disabled={syncBusy}>
|
||
Forget
|
||
</button>
|
||
)}
|
||
</div>
|
||
{/* Android grants a *folder*, so which file in it is the
|
||
library is a separate choice. Without this the app
|
||
assumes "library.json" and would create an empty second
|
||
library beside one named anything else. */}
|
||
{sync.picker_based && sync.chosen && sync.granted && (
|
||
<div className="field">
|
||
<label>Library file in that folder</label>
|
||
{docChoices === null ? (
|
||
<div className="hint">Looking…</div>
|
||
) : (
|
||
<>
|
||
<select
|
||
value={sync.document_name ?? ""}
|
||
onChange={(e) => chooseDocumentName(e.target.value)}
|
||
disabled={syncBusy}
|
||
>
|
||
{/* The current name may not exist in the folder
|
||
yet (a library this device hasn't published),
|
||
so it's always offered. */}
|
||
{[
|
||
...new Set(
|
||
[sync.document_name ?? "library.json", ...docChoices].filter(
|
||
Boolean,
|
||
),
|
||
),
|
||
].map((n) => (
|
||
<option key={n} value={n}>
|
||
{n}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{docChoices.length === 0 && (
|
||
<span className="hint">
|
||
No library files here yet — a new one will be created.
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Only shown while an old library.db is still waiting; the
|
||
offer disappears for good once it's been converted. */}
|
||
{sync.legacy_library && (
|
||
<>
|
||
<div className="hint">
|
||
An older library from a previous version is still on this device. Convert
|
||
it to bring your collection across.
|
||
</div>
|
||
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
||
{sync.legacy_library}
|
||
</div>
|
||
<div className="row">
|
||
<button
|
||
className="primary"
|
||
onClick={() => convertLegacy(sync.legacy_library!)}
|
||
disabled={syncBusy}
|
||
>
|
||
Convert old library
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
<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}
|
||
onOpenLibrary={pickLibrary}
|
||
onNewLibrary={newLibrary}
|
||
undo={undo}
|
||
onRunUndo={runUndo}
|
||
/>
|
||
</HoverPreviewProvider>
|
||
);
|
||
}
|