ba5ae37111
P5 backend (the deferred items): - conflict-aware cloud sync via optimistic concurrency: the server versions each backup blob (file mtime) and rejects a push whose base version is stale (409), so a second device can't silently overwrite a newer cloud copy. The client tracks its last-synced version; the SyncStatusIndicator surfaces a "Sync conflict" with one-click resolution (Use cloud / Keep this device), and the Settings push offers the same choice. - revocable invites: owner can rotate a campaign's invite code (kills the old one) and remove a member (drops them + deletes their published characters). (Skipped editor/player roles — no enforcement target in the current model.) - image storage: deliberately deferred (live images already stream de-duped on a separate channel; a backup blob-store is infra with no correctness gain). P7 — subclass/feat/multiclass depth: - feats are now first-class tracked records (name + effect text) in a Feats & Features sheet section, not buried in notes. Dexie v15 migration. - 5e multiclass spell-slot calculator: a pure multiclassSlots() (full ×1, half ÷2, third ÷3 → full-caster table) + a sheet control to fill the slot table for multiclass casters. (Honest scope: tracking + slots, not per-subclass feature automation.) P8 — test hardening: v2-surfaces e2e (feats, multiclass calc, signals bell, World nav) + server tests (blob versioning, invite rotation, member removal) + multiclass unit tests. Gate green: 283 unit + 35 e2e + 2 realtime. App + server build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
import { useState, type ComponentType, type ReactNode } from 'react';
|
|
import { useNavigate } from '@tanstack/react-router';
|
|
import {
|
|
Palette,
|
|
Sun,
|
|
Moon,
|
|
Download,
|
|
Upload,
|
|
Sparkles,
|
|
Cloud,
|
|
ScrollText,
|
|
TriangleAlert,
|
|
type LucideProps,
|
|
} from 'lucide-react';
|
|
import { useUiStore, type Theme } from '@/stores/uiStore';
|
|
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
|
|
import { pickTextFile } from '@/lib/io/file';
|
|
import { seedSampleCampaign } from '@/lib/sample';
|
|
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
|
|
import { AssistantSettings } from './AssistantSettings';
|
|
import { CloudCampaigns } from './CloudCampaigns';
|
|
import { Page, PageHeader } from '@/components/ui/Page';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Modal } from '@/components/ui/Modal';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
/** A Living-Codex setting card: section eyebrow + a gilt hairline over its rows. */
|
|
function SettingsCard({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
|
|
return (
|
|
<section className={cn('rounded-xl border border-line bg-panel p-4 sm:p-5', className)}>
|
|
<h2 className="smallcaps text-[11px] text-muted">{label}</h2>
|
|
<hr className="gilt-rule mt-2 mb-1" />
|
|
<div>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** An icon-tile row: leading rounded tile, title + description, trailing controls. */
|
|
function SettingsRow({
|
|
icon: Icon,
|
|
title,
|
|
desc,
|
|
children,
|
|
}: {
|
|
icon: ComponentType<LucideProps>;
|
|
title: ReactNode;
|
|
desc?: ReactNode;
|
|
children?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-line py-4 last:border-b-0">
|
|
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-deep" aria-hidden>
|
|
<Icon size={18} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-display text-[15px] font-semibold text-ink">{title}</div>
|
|
{desc && <div className="mt-0.5 text-xs text-faint">{desc}</div>}
|
|
</div>
|
|
{children && <div className="flex shrink-0 flex-wrap items-center gap-2">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SettingsPage() {
|
|
const theme = useUiStore((s) => s.theme);
|
|
const setTheme = useUiStore((s) => s.setTheme);
|
|
const setActiveCampaign = useUiStore((s) => s.setActiveCampaign);
|
|
const navigate = useNavigate();
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
|
const [confirmClear, setConfirmClear] = useState(false);
|
|
|
|
const doImport = async () => {
|
|
const text = await pickTextFile();
|
|
if (text) setConfirmRestore(text);
|
|
};
|
|
const runRestore = async () => {
|
|
if (!confirmRestore) return;
|
|
try {
|
|
await restoreBackup(confirmRestore);
|
|
setMsg('Backup restored.');
|
|
} catch (e) {
|
|
setMsg(e instanceof BackupError ? e.message : 'Restore failed.');
|
|
}
|
|
setConfirmRestore(null);
|
|
};
|
|
const loadSample = async () => {
|
|
const id = await seedSampleCampaign();
|
|
setActiveCampaign(id);
|
|
void navigate({ to: '/dashboard' });
|
|
};
|
|
|
|
return (
|
|
<Page>
|
|
<PageHeader eyebrow="Preferences" title="Settings" subtitle="Appearance, backups, and data." />
|
|
|
|
<div className="space-y-6">
|
|
<SettingsCard label="Appearance">
|
|
<SettingsRow
|
|
icon={theme === 'dark' ? Moon : Sun}
|
|
title="Theme"
|
|
desc="Parchment by day, candlelight by night."
|
|
>
|
|
{(['dark', 'light'] as Theme[]).map((t) => (
|
|
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} size="sm" onClick={() => setTheme(t)} className="capitalize">
|
|
{t}
|
|
</Button>
|
|
))}
|
|
</SettingsRow>
|
|
</SettingsCard>
|
|
|
|
<SettingsCard label="Data">
|
|
<SettingsRow icon={Download} title="Export backup" desc="Download a single JSON file with every campaign and all of its data.">
|
|
<Button variant="secondary" size="sm" onClick={() => void exportBackup()}>Export backup</Button>
|
|
</SettingsRow>
|
|
<SettingsRow icon={Upload} title="Import backup" desc="Restore from a previously exported file. Replaces current data.">
|
|
<Button variant="secondary" size="sm" onClick={doImport}>Import backup…</Button>
|
|
</SettingsRow>
|
|
<SettingsRow icon={Sparkles} title="Sample campaign" desc="Load a ready-made campaign to explore the app.">
|
|
<Button variant="secondary" size="sm" onClick={loadSample}>Load sample campaign</Button>
|
|
</SettingsRow>
|
|
{msg && <p className={cn('mt-3 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
|
|
</SettingsCard>
|
|
|
|
<AssistantSettings />
|
|
|
|
<CloudSync />
|
|
|
|
<CloudCampaigns />
|
|
|
|
<SettingsCard label="Data sources & licenses">
|
|
<SettingsRow icon={ScrollText} title="Attribution" desc="Open-licensed game content powering the compendium.">
|
|
<Palette className="text-faint" size={18} aria-hidden />
|
|
</SettingsRow>
|
|
<ul className="space-y-1 pt-3 text-sm text-muted">
|
|
<li><span className="text-ink">D&D 5e</span> — SRD content via <a className="text-accent hover:underline" href="https://open5e.com" target="_blank" rel="noreferrer">Open5e</a> (OGL 1.0a / CC-BY-4.0).</li>
|
|
<li><span className="text-ink">Pathfinder 2e</span> — content from the <a className="text-accent hover:underline" href="https://github.com/foundryvtt/pf2e" target="_blank" rel="noreferrer">Foundry VTT pf2e</a> project & Archives of Nethys (OGL / ORC / Paizo Community Use).</li>
|
|
<li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry & others.</li>
|
|
</ul>
|
|
</SettingsCard>
|
|
|
|
<section className="rounded-xl border border-danger/40 bg-danger/5 p-4 sm:p-5">
|
|
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-danger">
|
|
<TriangleAlert size={15} aria-hidden /> Danger zone
|
|
</h2>
|
|
<p className="mb-3 mt-1 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
|
|
<Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
|
|
</section>
|
|
</div>
|
|
|
|
<Modal
|
|
open={!!confirmRestore}
|
|
onClose={() => setConfirmRestore(null)}
|
|
title="Restore backup?"
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setConfirmRestore(null)}>Cancel</Button>
|
|
<Button variant="danger" onClick={runRestore}>Replace all data</Button>
|
|
</>
|
|
}
|
|
>
|
|
<p className="text-sm text-muted">Restoring <strong className="text-ink">replaces all current data</strong> with the backup. Export first if unsure.</p>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={confirmClear}
|
|
onClose={() => setConfirmClear(false)}
|
|
title="Clear all data?"
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setConfirmClear(false)}>Cancel</Button>
|
|
<Button variant="danger" onClick={async () => { await clearAllData(); setActiveCampaign(null); setConfirmClear(false); setMsg('All data cleared.'); }}>Delete everything</Button>
|
|
</>
|
|
}
|
|
>
|
|
<p className="text-sm text-muted">This deletes every campaign, character, encounter, note, map, and homebrew entry. This cannot be undone.</p>
|
|
</Modal>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function CloudSync() {
|
|
const [user, setUser] = useState<string | null>(cloudUsername());
|
|
const [u, setU] = useState('');
|
|
const [p, setP] = useState('');
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [confirmPull, setConfirmPull] = useState(false);
|
|
|
|
const run = async (fn: () => Promise<void>) => {
|
|
setBusy(true); setMsg(null);
|
|
try { await fn(); } catch (e) { setMsg(e instanceof CloudError ? e.message : 'Something went wrong.'); } finally { setBusy(false); }
|
|
};
|
|
const auth = (which: 'in' | 'up') => run(async () => {
|
|
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
|
|
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
|
|
});
|
|
const push = () => run(async () => {
|
|
const r = await pushBackup();
|
|
if (r.ok) { setMsg(`Backed up to cloud (${Math.round(r.bytes / 1024)} KB).`); return; }
|
|
// Conflict: the cloud changed on another device since this one last synced.
|
|
const useCloud = window.confirm('The cloud copy was changed on another device since you last synced.\n\nOK — replace THIS device with the cloud copy.\nCancel — overwrite the CLOUD with this device.');
|
|
if (useCloud) { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); }
|
|
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
|
|
});
|
|
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
|
|
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
|
|
|
return (
|
|
<SettingsCard label="Cloud sync (optional)">
|
|
<SettingsRow
|
|
icon={Cloud}
|
|
title="Cloud sync"
|
|
desc="Sign in to back up your campaigns and sync them across devices. Local-first stays the default — nothing leaves this device until you push."
|
|
>
|
|
{user && <span className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></span>}
|
|
</SettingsRow>
|
|
{user ? (
|
|
<div className="space-y-3 pt-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="primary" disabled={busy} onClick={push}>⬆ Back up to cloud</Button>
|
|
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}>⬇ Restore from cloud</Button>
|
|
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap items-center gap-2 pt-4">
|
|
<Input className="max-w-40" placeholder="Username" value={u} onChange={(e) => setU(e.target.value)} aria-label="Cloud username" />
|
|
<Input className="max-w-40" type="password" placeholder="Password" value={p} onChange={(e) => setP(e.target.value)} aria-label="Cloud password" />
|
|
<Button variant="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
|
|
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
|
|
</div>
|
|
)}
|
|
{msg && <p className="mt-3 text-sm text-accent">{msg}</p>}
|
|
|
|
<Modal open={confirmPull} onClose={() => setConfirmPull(false)} title="Restore from cloud?"
|
|
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
|
|
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
|
|
</Modal>
|
|
</SettingsCard>
|
|
);
|
|
}
|