UI overhaul "Living Codex" E: per-screen layouts
Ported all 9 screens to the prototype layout (visual only; all data wiring, text, aria-labels, and testids preserved) via parallel agents + central verification: - Dashboard: editorial hero, party roster w/ HP meters + avatars, quests, threats, rolls. - Campaigns: cover-art deck cards + gilt rule + footer. - Character sheet + roster: hero header, StatCoin abilities, prof rows; grimoire cards. - Combat: glowing initiative rows (PC gilt / foe ember), condition badges, log. - Dice: die-pool glyphs, adv/dis segmented toggle, crit/fumble stage, history. - Compendium: Spectral stat blocks (ember headers, ability row). - Settings: grouped icon-tile cards. Live Session: room-code card, seat grid, player board. - Battle Map: carded list + tooled editor chrome (canvas untouched). Regression fixes after the ports: dice die-buttons aria-label `d4` not `Roll 1d4` (was colliding with the primary Roll button); map "Open player view" link uses an ExternalLink icon (test updated, ↗ glyph removed); seat-claim card gets a stable data-testid="seat-option" (redesign moved rounded-lg→rounded-xl). 223 unit + 34 e2e + 2 realtime green; tsc + eslint + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
@@ -13,6 +25,43 @@ 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);
|
||||
@@ -44,50 +93,61 @@ export function SettingsPage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title="Settings" subtitle="Appearance, backups, and data." />
|
||||
<PageHeader eyebrow="Preferences" title="Settings" subtitle="Appearance, backups, and data." />
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</h2>
|
||||
<div className="flex gap-2">
|
||||
{(['dark', 'light'] as Theme[]).map((t) => (
|
||||
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} onClick={() => setTheme(t)} className="capitalize">
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => void exportBackup()}>Export backup</Button>
|
||||
<Button variant="secondary" onClick={doImport}>Import backup…</Button>
|
||||
<Button variant="secondary" onClick={loadSample}>Load sample campaign</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">Backups include every campaign and all of its data, as a single JSON file.</p>
|
||||
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
|
||||
</section>
|
||||
<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 />
|
||||
<AssistantSettings />
|
||||
|
||||
<CloudSync />
|
||||
<CloudSync />
|
||||
|
||||
<CloudCampaigns />
|
||||
<CloudCampaigns />
|
||||
|
||||
<section className="rounded-lg border border-line bg-panel p-4">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources & licenses</h2>
|
||||
<ul className="space-y-1 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>
|
||||
</section>
|
||||
<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-lg border border-danger/40 bg-danger/5 p-4">
|
||||
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
|
||||
<p className="mb-3 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>
|
||||
<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}
|
||||
@@ -141,12 +201,16 @@ function CloudSync() {
|
||||
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-line bg-panel p-4">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2>
|
||||
<p className="mb-3 text-sm text-muted">Sign in to back up your campaigns and sync them across devices. Local-first stays the default — nothing leaves this device until you push.</p>
|
||||
<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-2">
|
||||
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
|
||||
<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>
|
||||
@@ -154,19 +218,19 @@ function CloudSync() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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-2 text-sm text-accent">{msg}</p>}
|
||||
{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>
|
||||
</section>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user