Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
491
src/App.tsx
491
src/App.tsx
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
|
||||
import {
|
||||
ColorDistributionBar,
|
||||
formatRelativeTime,
|
||||
HoverPreviewProvider,
|
||||
IconButton,
|
||||
Icons,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
usePersistedState,
|
||||
} from "./components";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { SyncIndicator, SyncPanel } from "./SyncPanel";
|
||||
import { CardEntry } from "./views/CardEntry";
|
||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||
import { CardSearch } from "./views/CardSearch";
|
||||
@@ -39,14 +41,6 @@ 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" />
|
||||
@@ -145,21 +139,6 @@ const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = {
|
||||
),
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
@@ -231,9 +210,10 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
|
||||
// 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. */
|
||||
/** 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() {
|
||||
@@ -242,14 +222,11 @@ export default function App() {
|
||||
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.
|
||||
// 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 [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);
|
||||
// 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.
|
||||
@@ -439,212 +416,82 @@ export default function App() {
|
||||
}
|
||||
|
||||
|
||||
/** Refresh the synced-folder grant state. Never throws: "no folder chosen" is
|
||||
* a normal state, reported in the panel rather than as an error. */
|
||||
/** 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 () => {
|
||||
setSyncBusy(true);
|
||||
try {
|
||||
setSync(await api.syncStatus());
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
console.error("syncStatus failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Once at startup: the automatic sync below needs to know whether a folder is
|
||||
// configured at all.
|
||||
// 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, 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.
|
||||
// And whenever the Info tab is opened, so "synced 5m ago" is current there.
|
||||
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.
|
||||
* Bring every view into line after the library may have changed wholesale:
|
||||
* signing in or out, 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`; and each tab holds rows it fetched earlier.
|
||||
*/
|
||||
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());
|
||||
const refreshEverything = useCallback(() => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
||||
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;
|
||||
/** 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);
|
||||
}
|
||||
return report;
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
api.syncStatus().then(setSync).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 (!syncGranted) return;
|
||||
if (!syncActive) 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.
|
||||
// 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(() => {});
|
||||
@@ -652,6 +499,7 @@ export default function App() {
|
||||
|
||||
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
|
||||
@@ -663,49 +511,9 @@ export default function App() {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", run);
|
||||
window.removeEventListener("online", 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);
|
||||
}
|
||||
}
|
||||
}, [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. */
|
||||
@@ -762,23 +570,10 @@ export default function App() {
|
||||
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<span className="path" title={status?.library_path ?? ""}>
|
||||
{status?.library_path ?? ""}
|
||||
<span className="path" title={status?.library_label ?? ""}>
|
||||
{status?.library_label ?? ""}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<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 && (
|
||||
@@ -844,8 +639,7 @@ export default function App() {
|
||||
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.
|
||||
The reference data stays on this device — only your library is synced.
|
||||
</p>
|
||||
{status?.is_mobile && (
|
||||
<p className="hint">
|
||||
@@ -865,20 +659,20 @@ export default function App() {
|
||||
</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
|
||||
) : /* Keyed on the sync account: switching to a different one 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_path} onOfferUndo={offerUndo} />
|
||||
<LibraryBrowser key={status?.library_label} onOfferUndo={offerUndo} />
|
||||
) : tab === "search" ? (
|
||||
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
|
||||
<CardSearch key={status?.library_label} onOfferUndo={offerUndo} />
|
||||
) : tab === "decks" ? (
|
||||
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
|
||||
<DeckView key={status?.library_label} refreshSignal={deckRefreshSignal} />
|
||||
) : tab === "news" ? (
|
||||
// Library-independent, so unlike the others it isn't keyed on the
|
||||
// document — switching libraries doesn't change the news.
|
||||
// account — switching libraries doesn't change the news.
|
||||
<NewsView />
|
||||
) : (
|
||||
// The per-install status page: card data, library sync, and the
|
||||
@@ -889,9 +683,6 @@ export default function App() {
|
||||
// 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}
|
||||
@@ -903,124 +694,19 @@ export default function App() {
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<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} />
|
||||
@@ -1058,8 +744,7 @@ export default function App() {
|
||||
<CommandPalette
|
||||
onNavigate={setTab}
|
||||
onUpdateCardData={updateCardData}
|
||||
onOpenLibrary={pickLibrary}
|
||||
onNewLibrary={newLibrary}
|
||||
onSyncNow={() => runSync().catch(() => {})}
|
||||
undo={undo}
|
||||
onRunUndo={runUndo}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api, CardRef, Deck } from "./api";
|
||||
import { DialogFrame, useClickPreview } from "./components";
|
||||
|
||||
// Kept in step with App.tsx's Tab union by hand — it isn't exported.
|
||||
type Tab = "add" | "library" | "search" | "decks" | "news";
|
||||
type Tab = "add" | "library" | "search" | "decks" | "news" | "info";
|
||||
|
||||
interface PaletteItem {
|
||||
key: string;
|
||||
@@ -38,15 +38,13 @@ function subsequenceMatch(query: string, text: string): boolean {
|
||||
export function CommandPalette({
|
||||
onNavigate,
|
||||
onUpdateCardData,
|
||||
onOpenLibrary,
|
||||
onNewLibrary,
|
||||
onSyncNow,
|
||||
undo,
|
||||
onRunUndo,
|
||||
}: {
|
||||
onNavigate: (tab: Tab) => void;
|
||||
onUpdateCardData: () => void;
|
||||
onOpenLibrary: () => void;
|
||||
onNewLibrary: () => void;
|
||||
onSyncNow: () => void;
|
||||
/** The most recent undoable action, if its window hasn't lapsed yet. */
|
||||
undo: { message: string; run: () => void | Promise<void> } | null;
|
||||
onRunUndo: () => void;
|
||||
@@ -98,8 +96,8 @@ export function CommandPalette({
|
||||
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
||||
{ key: "nav-news", label: "Go to News", run: () => onNavigate("news") },
|
||||
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
||||
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
|
||||
{ key: "new-library", label: "Create new library file…", run: onNewLibrary },
|
||||
{ key: "sync-now", label: "Sync library now", run: onSyncNow },
|
||||
{ key: "nav-sync", label: "Sync settings", run: () => onNavigate("info") },
|
||||
];
|
||||
|
||||
const items: PaletteItem[] = [
|
||||
|
||||
424
src/SyncPanel.tsx
Normal file
424
src/SyncPanel.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api, FirstSync, SyncDevice, SyncReport, SyncStatus } from "./api";
|
||||
import { ConfirmDialog, formatRelativeTime } from "./components";
|
||||
|
||||
/** The server's answer when this device's token stops working. */
|
||||
const SIGNED_OUT_BY_SERVER = "This device is no longer signed in to the sync server.";
|
||||
|
||||
/** A one-line summary of a sync, for the panel's outcome line. */
|
||||
export function describeSync(report: SyncReport): string {
|
||||
if (report.needs_choice) return "Choose how to combine the two libraries below.";
|
||||
const r = report.report;
|
||||
const parts: string[] = [];
|
||||
if (report.changed) {
|
||||
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(`Brought in ${bits.join(", ")} from your other devices.`);
|
||||
}
|
||||
if (report.published) parts.push("Sent this device's changes.");
|
||||
if (parts.length === 0) parts.push("Already up to date.");
|
||||
// Conflicts are the one thing worth spelling out — the user may want to
|
||||
// correct a value that lost.
|
||||
if (r.conflicts.length > 0) {
|
||||
parts.push(`Changed in two places, so the server's version was kept for: ${r.conflicts.join("; ")}.`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sync state for the top bar. Clicking it opens the Info tab, where
|
||||
* the details and controls live.
|
||||
*/
|
||||
export function SyncIndicator({
|
||||
sync,
|
||||
syncing,
|
||||
onClick,
|
||||
}: {
|
||||
sync: SyncStatus | null;
|
||||
syncing: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (!sync) return null;
|
||||
let tone: "ok" | "warn" | "off" = "ok";
|
||||
let label: string;
|
||||
if (!sync.signed_in) {
|
||||
tone = "off";
|
||||
label = sync.last_error === SIGNED_OUT_BY_SERVER ? "Signed out" : "Not syncing";
|
||||
} else if (sync.needs_choice) {
|
||||
tone = "warn";
|
||||
label = "Sync needs you";
|
||||
} else if (syncing) {
|
||||
label = "Syncing…";
|
||||
} else if (sync.last_error) {
|
||||
tone = "warn";
|
||||
label = sync.pending_changes ? "Offline — changes will sync" : "Offline";
|
||||
} else if (sync.pending_changes) {
|
||||
tone = "warn";
|
||||
label = "Not yet synced";
|
||||
} else {
|
||||
label = "Synced";
|
||||
}
|
||||
const title = sync.last_error
|
||||
? `Last sync failed: ${sync.last_error}`
|
||||
: sync.last_synced_at
|
||||
? `Last synced ${formatRelativeTime(sync.last_synced_at)}`
|
||||
: label;
|
||||
return (
|
||||
<button className={`sync-indicator sync-indicator-${tone}`} onClick={onClick} title={title}>
|
||||
<span className={`status-dot ${tone === "ok" ? "on" : tone === "warn" ? "warn" : "off"}`} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Info tab's library sync section: signing in to a sync server, the
|
||||
* first-sync choice, the account's devices, and library backups.
|
||||
*/
|
||||
export function SyncPanel({
|
||||
sync,
|
||||
setSync,
|
||||
syncing,
|
||||
runSync,
|
||||
onLibraryReplaced,
|
||||
}: {
|
||||
sync: SyncStatus;
|
||||
setSync: (s: SyncStatus) => void;
|
||||
syncing: boolean;
|
||||
/** Sync now; resolves with the report, or null when nothing ran. */
|
||||
runSync: () => Promise<SyncReport | null>;
|
||||
/** The library may have changed wholesale (signed in, imported, adopted the
|
||||
* server's copy): every view should refetch. */
|
||||
onLibraryReplaced: () => void;
|
||||
}) {
|
||||
const [serverUrl, setServerUrl] = useState(sync.server_url ?? "");
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [outcome, setOutcome] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [devices, setDevices] = useState<SyncDevice[] | null>(null);
|
||||
const [confirm, setConfirm] = useState<
|
||||
{ kind: "use_server" } | { kind: "revoke"; device: SyncDevice } | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl && sync.server_url) setServerUrl(sync.server_url);
|
||||
}, [sync.server_url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// The device list is only worth a round trip while it's on screen.
|
||||
useEffect(() => {
|
||||
if (!sync.signed_in) {
|
||||
setDevices(null);
|
||||
return;
|
||||
}
|
||||
api.syncDevices().then(setDevices).catch(() => setDevices(null));
|
||||
}, [sync.signed_in, sync.account?.id]);
|
||||
|
||||
async function act(work: () => Promise<string | null>) {
|
||||
setBusy(true);
|
||||
setOutcome(null);
|
||||
try {
|
||||
const message = await work();
|
||||
if (message) setOutcome({ ok: true, message });
|
||||
} catch (err) {
|
||||
// Backing out of a sign-in is a choice, not a failure worth flagging.
|
||||
if (!String(err).includes("Sign-in cancelled")) {
|
||||
setOutcome({ ok: false, message: String(err) });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
api.syncStatus().then(setSync).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const signIn = () =>
|
||||
act(async () => {
|
||||
if (!serverUrl.trim()) throw new Error("Enter your sync server's address.");
|
||||
// Shows "finish signing in in your browser" while this waits.
|
||||
setSync({ ...sync, signing_in: true });
|
||||
const s = await api.syncSignIn(serverUrl.trim(), deviceName.trim() || undefined);
|
||||
setSync(s);
|
||||
onLibraryReplaced();
|
||||
return s.needs_choice ? null : `Signed in as ${s.account?.username}. Your library is syncing.`;
|
||||
});
|
||||
|
||||
const cancelSignIn = () => api.syncCancelSignIn().catch(() => {});
|
||||
|
||||
const signOut = () =>
|
||||
act(async () => {
|
||||
setSync(await api.syncSignOut());
|
||||
onLibraryReplaced();
|
||||
return "Signed out. Your library stays on this device and keeps working offline.";
|
||||
});
|
||||
|
||||
const syncNow = () =>
|
||||
act(async () => {
|
||||
const r = await runSync();
|
||||
return r ? describeSync(r) : "A sync was already running.";
|
||||
});
|
||||
|
||||
const connect = (choice: FirstSync) =>
|
||||
act(async () => {
|
||||
const r = await api.syncConnect(choice);
|
||||
onLibraryReplaced();
|
||||
return r ? describeSync(r) : null;
|
||||
});
|
||||
|
||||
const revoke = (device: SyncDevice) =>
|
||||
act(async () => {
|
||||
setDevices(await api.syncRevokeDevice(device.id));
|
||||
return `Signed out ${device.name}.`;
|
||||
});
|
||||
|
||||
const exportLibrary = () =>
|
||||
act(async () => {
|
||||
const path = await save({
|
||||
title: "Save a backup of your library",
|
||||
defaultPath: `mtgc-library-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
filters: [{ name: "mtgc library", extensions: ["json"] }],
|
||||
});
|
||||
if (typeof path !== "string") return null;
|
||||
await api.exportLibraryDocument(path);
|
||||
return `Saved to ${path}.`;
|
||||
});
|
||||
|
||||
const importLibrary = (preset?: string) =>
|
||||
act(async () => {
|
||||
const path =
|
||||
preset ??
|
||||
(await open({
|
||||
title: "Import a library file",
|
||||
filters: [
|
||||
{ name: "mtgc library", extensions: ["json", "db"] },
|
||||
{ name: "All files", extensions: ["*"] },
|
||||
],
|
||||
}));
|
||||
if (typeof path !== "string") return null;
|
||||
const r = await api.importLibraryFile(path);
|
||||
onLibraryReplaced();
|
||||
return `Imported ${r.cards} card${r.cards === 1 ? "" : "s"} and ${r.decks} deck${
|
||||
r.decks === 1 ? "" : "s"
|
||||
}, combined with what was already here.`;
|
||||
});
|
||||
|
||||
const account = sync.account;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ margin: 0 }}>Your library</h2>
|
||||
|
||||
{!sync.signed_in ? (
|
||||
<>
|
||||
<div className="hint">
|
||||
Your library is kept on this device and works offline. Sign in to a sync server to
|
||||
share it with your other devices — edits made in two places are combined, not
|
||||
overwritten.
|
||||
</div>
|
||||
{sync.last_error === SIGNED_OUT_BY_SERVER && (
|
||||
<div className="sync-bad">
|
||||
✕ This device was signed out by the server (removed from another device, perhaps).
|
||||
Sign in again to resume syncing.
|
||||
</div>
|
||||
)}
|
||||
{sync.signing_in ? (
|
||||
<>
|
||||
<div className="sync-ok">Finish signing in in your browser…</div>
|
||||
<div className="row">
|
||||
<button onClick={cancelSignIn}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="sync-server">Sync server</label>
|
||||
<input
|
||||
id="sync-server"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="mtgc.example.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !busy && signIn()}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="sync-device">Name for this device (optional)</label>
|
||||
<input
|
||||
id="sync-device"
|
||||
placeholder="e.g. Desktop, Phone"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="primary" onClick={signIn} disabled={busy || !serverUrl.trim()}>
|
||||
Sign in…
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="sync-folder">
|
||||
{account?.display_name && account.display_name !== account.username
|
||||
? `${account.display_name} (${account.username})`
|
||||
: account?.username}
|
||||
{account?.email && <span className="hint"> · {account.email}</span>}
|
||||
</div>
|
||||
<div className="hint" style={{ overflowWrap: "anywhere" }}>
|
||||
{sync.server_url}
|
||||
{account?.method === "oidc" ? " · single sign-on" : ""}
|
||||
</div>
|
||||
|
||||
{sync.needs_choice ? (
|
||||
<div className="sync-choice">
|
||||
<strong>This device and your account both have a library.</strong>
|
||||
<div className="hint">
|
||||
Combining keeps everything from both. Using the server's library replaces what's on
|
||||
this device.
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<button className="primary" onClick={() => connect("combine")} disabled={busy}>
|
||||
Combine them
|
||||
</button>
|
||||
<button onClick={() => setConfirm({ kind: "use_server" })} disabled={busy}>
|
||||
Use the server's library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={sync.last_error ? "sync-bad" : "sync-ok"}>
|
||||
{sync.last_error
|
||||
? `✕ Couldn't sync: ${sync.last_error}${
|
||||
sync.pending_changes ? " Your changes are safe here and will sync later." : ""
|
||||
}`
|
||||
: syncing
|
||||
? "Syncing…"
|
||||
: sync.pending_changes
|
||||
? "Changes on this device haven't synced yet."
|
||||
: sync.last_synced_at
|
||||
? `✓ Synced ${formatRelativeTime(sync.last_synced_at)}`
|
||||
: "✓ Synced"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
{!sync.needs_choice && (
|
||||
<button className="primary" onClick={syncNow} disabled={busy || syncing}>
|
||||
{busy || syncing ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={signOut} disabled={busy}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{outcome && (
|
||||
<div className={outcome.ok ? "sync-ok" : "sync-bad"}>
|
||||
{outcome.ok ? "✓ " : "✕ "}
|
||||
{outcome.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sync.signed_in && devices && devices.length > 0 && (
|
||||
<div className="field">
|
||||
<label>Signed-in devices</label>
|
||||
<ul className="sync-devices">
|
||||
{devices.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="sync-device-name">
|
||||
{d.name}
|
||||
{d.current && <span className="badge">This device</span>}
|
||||
</span>
|
||||
<span className="hint">seen {formatRelativeTime(d.last_seen_at)}</span>
|
||||
{!d.current && (
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={() => setConfirm({ kind: "revoke", device: d })}
|
||||
disabled={busy}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Backup</label>
|
||||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<button onClick={exportLibrary} disabled={busy}>
|
||||
Export library…
|
||||
</button>
|
||||
<button onClick={() => importLibrary()} disabled={busy}>
|
||||
Import library file…
|
||||
</button>
|
||||
</div>
|
||||
<span className="hint">
|
||||
Import combines a backup, or a library.json from an older version, with this one.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Only shown while an old library.db is still waiting; the offer
|
||||
disappears for good once it's been imported. */}
|
||||
{sync.legacy_library && (
|
||||
<>
|
||||
<div className="hint">
|
||||
An older library from a previous version is still on this device. Import it to bring
|
||||
your collection across.
|
||||
</div>
|
||||
<div className="sync-folder">{sync.legacy_library}</div>
|
||||
<div className="row">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => importLibrary(sync.legacy_library!)}
|
||||
disabled={busy}
|
||||
>
|
||||
Import old library
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirm?.kind === "use_server" && (
|
||||
<ConfirmDialog
|
||||
title="Use the server's library?"
|
||||
message="This device's library will be replaced by the one on the server. Anything only on this device will be lost — export a backup first if you might want it."
|
||||
confirmLabel="Replace this device's library"
|
||||
danger
|
||||
onCancel={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
setConfirm(null);
|
||||
connect("use_server");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{confirm?.kind === "revoke" && (
|
||||
<ConfirmDialog
|
||||
title={`Sign out ${confirm.device.name}?`}
|
||||
message="It will stop syncing until someone signs in on it again. Its library stays on it."
|
||||
confirmLabel="Sign out device"
|
||||
onCancel={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const d = confirm.device;
|
||||
setConfirm(null);
|
||||
revoke(d);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
src/api.ts
124
src/api.ts
@@ -136,7 +136,8 @@ export interface LibraryFilter {
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
library_path: string;
|
||||
/** Where the library syncs: "user @ host", or this device only. */
|
||||
library_label: string;
|
||||
reference_path: string;
|
||||
reference_ready: boolean;
|
||||
card_count: number;
|
||||
@@ -147,41 +148,74 @@ export interface Status {
|
||||
is_mobile: boolean;
|
||||
}
|
||||
|
||||
/** State of the user's library document — the JSON file they keep in whatever
|
||||
* folder they sync. The SQLite database behind it is internal and never shown. */
|
||||
/** The account a device is signed in to on a sync server. */
|
||||
export interface SyncAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
/** "local" (username/password) or "oidc" (single sign-on). */
|
||||
method: string;
|
||||
}
|
||||
|
||||
/** Library sync with a sync server. The library itself always lives on this
|
||||
* device and works offline; syncing exchanges edits with the other devices. */
|
||||
export interface SyncStatus {
|
||||
/** Whether a document has been chosen. */
|
||||
chosen: boolean;
|
||||
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
|
||||
* can be revoked and a desktop folder can be deleted or unmounted — both need
|
||||
* re-picking rather than first-time setup. */
|
||||
granted: boolean;
|
||||
label: string | null;
|
||||
/** True where the folder is chosen through the system picker (Android) rather
|
||||
* than by path — the one thing the UI branches on. */
|
||||
picker_based: boolean;
|
||||
/** A pre-document library.db from an older build, waiting to be converted. */
|
||||
signed_in: boolean;
|
||||
/** Kept after signing out, to offer signing back in. */
|
||||
server_url: string | null;
|
||||
account: SyncAccount | null;
|
||||
/** Unix seconds of the last completed sync. */
|
||||
last_synced_at: number | null;
|
||||
/** Why the last sync failed, until one succeeds. */
|
||||
last_error: string | null;
|
||||
/** The first sync found libraries both here and on the server; waiting on
|
||||
* `syncConnect` to say whether to combine them or take the server's. */
|
||||
needs_choice: boolean;
|
||||
/** Edits made here that the server hasn't acknowledged yet. */
|
||||
pending_changes: boolean;
|
||||
/** A sign-in is waiting on the browser. */
|
||||
signing_in: boolean;
|
||||
/** A pre-document library.db from an older build, waiting to be imported. */
|
||||
legacy_library: string | null;
|
||||
/** Which file in the granted folder is the library (Android). Null on
|
||||
* desktop, where the whole path is chosen at once. */
|
||||
document_name: string | null;
|
||||
}
|
||||
|
||||
/** One of the account's signed-in devices. */
|
||||
export interface SyncDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
/** Whether it's this device. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export type FirstSync = "combine" | "use_server";
|
||||
|
||||
export interface MergeReport {
|
||||
cards_changed: number;
|
||||
decks_changed: number;
|
||||
decks_removed: number;
|
||||
/** Fields both sides changed, where the server's value was kept. */
|
||||
conflicts: string[];
|
||||
}
|
||||
|
||||
/** What an import of a library file brought in. */
|
||||
export interface ImportReport {
|
||||
cards: number;
|
||||
decks: number;
|
||||
report: MergeReport;
|
||||
}
|
||||
|
||||
/** What one sync did. */
|
||||
export interface SyncReport {
|
||||
/** Whether the merge altered the local library. */
|
||||
/** Whether the merge altered this device's library. */
|
||||
changed: boolean;
|
||||
/** Whether the document was rewritten (skipped when we had nothing to add). */
|
||||
/** Whether the server's copy was replaced (skipped when there was nothing to add). */
|
||||
published: boolean;
|
||||
/** Conflict copies absorbed and cleaned up. */
|
||||
absorbed: number;
|
||||
report: {
|
||||
cards_changed: number;
|
||||
decks_changed: number;
|
||||
decks_removed: number;
|
||||
/** Fields both sides changed differently, described in user terms. */
|
||||
conflicts: string[];
|
||||
};
|
||||
/** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */
|
||||
needs_choice: boolean;
|
||||
report: MergeReport;
|
||||
}
|
||||
|
||||
/** A published or upcoming Magic set, from Scryfall. */
|
||||
@@ -299,22 +333,26 @@ export const api = {
|
||||
/** Adopt an already-built reference.db from `path` instead of downloading it. */
|
||||
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
|
||||
syncStatus: () => invoke<SyncStatus>("sync_status"),
|
||||
/** Merge the library document with local edits and publish the result.
|
||||
* Resolves with `null` when a sync was already running and this call stood
|
||||
* down — expected, since sync also fires automatically. */
|
||||
/** Sign in through the browser, then sync. Resolves once the user finishes
|
||||
* (or abandons) signing in there. */
|
||||
syncSignIn: (serverUrl: string, deviceName?: string) =>
|
||||
invoke<SyncStatus>("sync_sign_in", { serverUrl, deviceName: deviceName || null }),
|
||||
syncCancelSignIn: () => invoke<void>("sync_cancel_sign_in"),
|
||||
/** Sign this device out. The library stays here. */
|
||||
syncSignOut: () => invoke<SyncStatus>("sync_sign_out"),
|
||||
/** Merge with the server and publish local edits. Resolves with `null` when
|
||||
* not signed in, or a sync was already running and this call stood down —
|
||||
* expected, since sync also fires automatically. */
|
||||
syncNow: () => invoke<SyncReport | null>("sync_now"),
|
||||
/** Android: grant access to a folder through the system picker. */
|
||||
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
|
||||
/** Desktop: adopt a library document chosen with the file dialog. */
|
||||
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
|
||||
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
|
||||
/** Library files already in the chosen folder, for Android's second step. */
|
||||
syncListDocuments: () => invoke<string[]>("sync_list_documents"),
|
||||
/** Android: choose which file in the granted folder is the library. */
|
||||
syncSetDocumentName: (name: string) =>
|
||||
invoke<SyncStatus>("sync_set_document_name", { name }),
|
||||
/** Convert a pre-document library.db into the current format. */
|
||||
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
|
||||
/** Answer the first-sync question. */
|
||||
syncConnect: (choice: FirstSync) => invoke<SyncReport | null>("sync_connect", { choice }),
|
||||
syncDevices: () => invoke<SyncDevice[]>("sync_devices"),
|
||||
/** Sign another device out (a lost phone, say); returns the remaining list. */
|
||||
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
|
||||
/** Merge a library file into this one: a JSON backup, or an old library.db. */
|
||||
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
|
||||
/** Save the whole library as JSON, as a backup. */
|
||||
exportLibraryDocument: (path: string) => invoke<void>("export_library_document", { path }),
|
||||
|
||||
/** Set releases and article links. `refresh` forces a fetch past the cache. */
|
||||
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),
|
||||
|
||||
@@ -1767,3 +1767,18 @@ function IconToggle({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact relative time, e.g. "2h ago" — for "updated"/"synced" stamps. */
|
||||
export 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`;
|
||||
}
|
||||
|
||||
@@ -489,6 +489,9 @@ input[type="checkbox"] {
|
||||
.status-dot.off {
|
||||
background: var(--faint);
|
||||
}
|
||||
.status-dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
/* Sidebar "your library" summary panel. */
|
||||
.lib-stats-head {
|
||||
@@ -784,7 +787,57 @@ input[type="checkbox"] {
|
||||
gap: var(--space-3);
|
||||
max-width: 480px;
|
||||
}
|
||||
/* The currently chosen synced folder. */
|
||||
/* Top-bar sync state; opens the Info tab. Quiet when all is well, so it
|
||||
only draws the eye when something needs attention. */
|
||||
.sync-indicator {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--fs-caption);
|
||||
padding: 5px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sync-indicator-warn {
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
/* First sync with an account that already has a library: a decision only the
|
||||
user can make, so it's set apart from the surrounding status text. */
|
||||
.sync-choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--warn);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.sync-devices {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.sync-devices li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sync-device-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.sync-devices .link-button {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
color: var(--danger);
|
||||
}
|
||||
/* The signed-in account. */
|
||||
.sync-folder {
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user