Files
mtgc/src/App.tsx

476 lines
17 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api";
import {
ColorDistributionBar,
HoverPreviewProvider,
IconButton,
Icons,
StatTile,
UndoBar,
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { CardSearch } from "./views/CardSearch";
import { DeckView } from "./views/DeckView";
type Tab = "add" | "library" | "search" | "decks";
const svg = (children: ReactNode, size = 20) => (
<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" />
</>,
),
},
];
/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */
function formatRelativeTime(epochSeconds: number): string {
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
if (diffSec < 30) return "just now";
const mins = Math.round(diffSec / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.round(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.round(months / 12)}y ago`;
}
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
let start = 0;
for (let i = 0; i < index; i++) start += stages[i].weight;
const end = start + stages[index].weight;
const [startPct, endPct] = [start * 100, end * 100];
if (overallPercent <= startPct) return 0;
if (overallPercent >= endPct) return 100;
if (endPct === startPct) return 100;
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
}
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
// This only controls the snackbar's visibility — the underlying undo action
// stays available in the command palette well past this (see `undo` below),
// so missing the snackbar isn't the last chance to undo.
const UNDO_SNACKBAR_MS = 8000;
export default function App() {
const [status, setStatus] = useState<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);
const lastProgress = useRef<IngestProgress | null>(null);
// Lives here (not in LibraryBrowser) so it's reachable from the command
// palette, which is mounted at the app level and outlives any one tab's
// view across tab switches. Unlike the snackbar, this has no timeout —
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
// so an undo that's missed in the snackbar is still reachable via Alt+/.
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise<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 () => {
setStatus(await api.getStatus());
}, []);
useEffect(() => {
refreshStatus();
const un = listen<IngestProgress>("ingest-progress", (e) => {
lastProgress.current = e.payload;
setProgress(e.payload);
});
return () => {
un.then((f) => f());
};
}, [refreshStatus]);
// Force a re-render every minute so the "Updated Xh ago" stamp stays fresh
// over a long session without needing an actual status refetch.
const [, tick] = useState(0);
useEffect(() => {
const interval = setInterval(() => tick((n) => n + 1), 60000);
return () => clearInterval(interval);
}, []);
const ready = status?.reference_ready ?? false;
// Keep the sidebar's library summary fresh: fetch once card data is ready,
// then poll (cards/decks can change from any tab without a shared event bus).
useEffect(() => {
if (!ready) {
setLibStats(null);
return;
}
let cancelled = false;
const refresh = () => api.getLibraryStats().then((s) => !cancelled && setLibStats(s));
refresh();
const interval = setInterval(refresh, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [ready]);
async function updateCardData() {
setBusy(true);
try {
const s = await api.updateCardData();
setStatus(s);
setProgress({
stage: "done",
message: `Loaded ${s.card_count.toLocaleString()} cards.`,
overall_percent: 100,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "done" ? null : p)), 4000);
} catch (err) {
const msg = String(err);
if (msg === "cancelled") {
setProgress({
stage: "cancelled",
message: "Cancelled.",
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "cancelled" ? null : p)), 3000);
} else {
setProgress({
stage: "error",
message: `Failed: ${msg}`,
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
}
} finally {
setBusy(false);
}
}
async function cancelUpdate() {
await api.cancelUpdateCardData();
}
async function switchLibrary(path: string) {
try {
setStatus(await api.setLibraryPath(path));
} catch (err) {
setProgress({
stage: "error",
message: `Couldn't open that library: ${String(err)}`,
overall_percent: 0,
stages: lastProgress.current?.stages ?? [],
});
}
}
async function openLibrary() {
const path = await open({
title: "Open an existing library.db",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") await switchLibrary(path);
}
async function newLibrary() {
const path = await save({
title: "Create a new library.db (put it in a synced folder)",
defaultPath: "library.db",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") await switchLibrary(path);
}
return (
<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">
{NAV.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">
{!ready ? (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
) : (
<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>
)}
</div>
</nav>
<div className="main">
<header className="topbar">
<span className="path" title={status?.library_path ?? ""}>
{status?.library_path ?? ""}
</span>
<button onClick={openLibrary} disabled={busy}>
{ICON_OPEN_LIBRARY}
Open Library
</button>
<button onClick={newLibrary} disabled={busy}>
{ICON_NEW_LIBRARY}
New Library
</button>
<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">
{!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. Click <strong>Download card data</strong> to fetch
the latest cards from Scryfall (a few hundred MB, about a minute). The
reference data stays on this device — only your library file is meant to
be synced.
</p>
<button className="primary big" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Download card data"}
</button>
</div>
) : tab === "add" ? (
<CardEntry />
) : tab === "library" ? (
<LibraryBrowser onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch onOfferUndo={offerUndo} />
) : (
<DeckView refreshSignal={deckRefreshSignal} />
)}
</div>
</div>
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
)}
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onNewLibrary={newLibrary}
undo={undo}
onRunUndo={runUndo}
/>
</HoverPreviewProvider>
);
}