Files
mtgc/src/SyncPanel.tsx
Garrett Mills 7415289257 Info page: show the app version, two-column layout on wide screens
An About panel shows the installed version (from Tauri at runtime, since
release builds stamp it at build time; dev builds are labelled as such).
On wide screens the page splits into panels in two columns — card
database, library stats and libraries on the left; sync and about on the
right — while narrow screens keep the single-column order. The sync panel
is now headed "Sync" and the Info page's summary "Library stats".

Closes #22
2026-10-23 23:27:58 -05:00

425 lines
15 KiB
TypeScript

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 }}>Sync</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);
}}
/>
)}
</>
);
}