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 ( ); } /** * 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; /** 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(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) { 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 ( <>

Sync

{!sync.signed_in ? ( <>
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.
{sync.last_error === SIGNED_OUT_BY_SERVER && (
✕ This device was signed out by the server (removed from another device, perhaps). Sign in again to resume syncing.
)} {sync.signing_in ? ( <>
Finish signing in in your browser…
) : ( <>
setServerUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && !busy && signIn()} disabled={busy} />
setDeviceName(e.target.value)} disabled={busy} />
)} ) : ( <>
{account?.display_name && account.display_name !== account.username ? `${account.display_name} (${account.username})` : account?.username} {account?.email && · {account.email}}
{sync.server_url} {account?.method === "oidc" ? " · single sign-on" : ""}
{sync.needs_choice ? (
This device and your account both have a library.
Combining keeps everything from both. Using the server's library replaces what's on this device.
) : (
{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"}
)}
{!sync.needs_choice && ( )}
)} {outcome && (
{outcome.ok ? "✓ " : "✕ "} {outcome.message}
)} {sync.signed_in && devices && devices.length > 0 && (
    {devices.map((d) => (
  • {d.name} {d.current && This device} seen {formatRelativeTime(d.last_seen_at)} {!d.current && ( )}
  • ))}
)}
Import combines a backup, or a library.json from an older version, with this one.
{/* Only shown while an old library.db is still waiting; the offer disappears for good once it's been imported. */} {sync.legacy_library && ( <>
An older library from a previous version is still on this device. Import it to bring your collection across.
{sync.legacy_library}
)} {confirm?.kind === "use_server" && ( setConfirm(null)} onConfirm={() => { setConfirm(null); connect("use_server"); }} /> )} {confirm?.kind === "revoke" && ( setConfirm(null)} onConfirm={() => { const d = confirm.device; setConfirm(null); revoke(d); }} /> )} ); }