Files
ttrpg_manager/src/features/settings/SettingsPage.tsx
T
NilsBriggen ba5ae37111 V2 P5-backend + P7 + P8: conflict-safe sync, revocable invites, feats, multiclass, tests
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>
2026-06-09 10:34:29 +02:00

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&amp;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 &amp; 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 &amp; 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>
);
}