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.