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:
@@ -27,7 +27,7 @@ test('a player claims a seat and manages their character; edits round-trip to th
|
|||||||
const player = await playerCtx.newPage();
|
const player = await playerCtx.newPage();
|
||||||
await player.goto(`/play?room=${code}`);
|
await player.goto(`/play?room=${code}`);
|
||||||
await expect(player.getByRole('heading', { name: 'Which character is yours?' })).toBeVisible();
|
await expect(player.getByRole('heading', { name: 'Which character is yours?' })).toBeVisible();
|
||||||
await player.locator('div.rounded-lg', { hasText: 'Lia the Brave' }).getByRole('button', { name: 'This is me' }).click();
|
await player.getByTestId('seat-option').filter({ hasText: 'Lia the Brave' }).getByRole('button', { name: 'This is me' }).click();
|
||||||
|
|
||||||
// GM: approve the seat request.
|
// GM: approve the seat request.
|
||||||
await gm.getByTestId('seat-requests').click();
|
await gm.getByTestId('seat-requests').click();
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ test('maps: upload, place token, reveal all, show to players', async ({ page })
|
|||||||
|
|
||||||
// Show to players, then the player view renders the battle map
|
// Show to players, then the player view renders the battle map
|
||||||
await page.getByRole('button', { name: 'Show to players' }).click();
|
await page.getByRole('button', { name: 'Show to players' }).click();
|
||||||
await page.getByRole('link', { name: 'Open player view ↗' }).click();
|
await page.getByRole('link', { name: 'Open player view' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
|
||||||
await expect(page.getByTestId('map-token')).toHaveCount(1);
|
await expect(page.getByTestId('map-token')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
import { campaignsRepo } from '@/lib/db/repositories';
|
import { campaignsRepo } from '@/lib/db/repositories';
|
||||||
import { seedSampleCampaign } from '@/lib/sample';
|
import { seedSampleCampaign } from '@/lib/sample';
|
||||||
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
||||||
@@ -8,6 +10,7 @@ import { useCampaigns } from './hooks';
|
|||||||
import type { Campaign } from '@/lib/schemas';
|
import type { Campaign } from '@/lib/schemas';
|
||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Codex';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
|
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ export function CampaignsPage() {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow="Your tables"
|
||||||
title="Campaigns"
|
title="Campaigns"
|
||||||
subtitle="Each campaign holds its own characters, encounters, and dice history."
|
subtitle="Each campaign holds its own characters, encounters, and dice history."
|
||||||
actions={
|
actions={
|
||||||
@@ -55,21 +59,25 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
|||||||
void navigate({ to: '/dashboard' });
|
void navigate({ to: '/dashboard' });
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl rounded-xl border border-line bg-panel p-6 text-center">
|
<div className="paper-grain relative mx-auto max-w-2xl overflow-hidden rounded-xl border border-line bg-panel p-7 text-center">
|
||||||
<h2 className="font-display text-2xl font-bold text-accent">Welcome to TTRPG Manager</h2>
|
<div className="relative z-[1]">
|
||||||
<p className="mx-auto mt-2 max-w-xl text-sm text-muted">
|
<div className="font-display text-sm italic text-accent">A local-first companion</div>
|
||||||
A local-first companion for D&D 5e & Pathfinder 2e — guided character building, combat
|
<h2 className="mt-1 font-display text-2xl font-bold text-accent-deep">Welcome to TTRPG Manager</h2>
|
||||||
tracking, a full battle-map VTT with fog & dynamic vision, a searchable compendium, and live
|
<p className="mx-auto mt-2 max-w-xl text-sm text-muted">
|
||||||
play your players join from their own devices. Everything is stored on this device.
|
A local-first companion for D&D 5e & Pathfinder 2e — guided character building, combat
|
||||||
</p>
|
tracking, a full battle-map VTT with fog & dynamic vision, a searchable compendium, and live
|
||||||
<div className="mt-4 grid gap-2 text-left sm:grid-cols-3">
|
play your players join from their own devices. Everything is stored on this device.
|
||||||
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
|
</p>
|
||||||
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
|
||||||
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
|
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
|
||||||
</div>
|
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
|
||||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
||||||
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
|
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
|
||||||
<Button variant="secondary" disabled={loading} onClick={() => void loadSample()}>{loading ? 'Loading…' : 'Try the sample campaign'}</Button>
|
</div>
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={() => void loadSample()}>{loading ? 'Loading…' : 'Try the sample campaign'}</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -77,9 +85,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
|||||||
|
|
||||||
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
|
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-surface p-3">
|
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
|
||||||
<div className="text-xl">{icon}</div>
|
<div className="text-xl" aria-hidden>{icon}</div>
|
||||||
<div className="mt-1 font-semibold text-ink">{title}</div>
|
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
|
||||||
<div className="text-xs text-muted">{desc}</div>
|
<div className="text-xs text-muted">{desc}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -96,35 +104,78 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
|
|||||||
void navigate({ to: '/dashboard' });
|
void navigate({ to: '/dashboard' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActive = activeId === campaign.id;
|
||||||
|
const systemLabel = campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e';
|
||||||
|
const created = new Date(campaign.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex flex-col rounded-lg border border-line bg-panel p-4">
|
<div
|
||||||
{activeId === campaign.id && (
|
className={cn(
|
||||||
<span className="absolute right-3 top-3 rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
|
'paper-grain group relative flex flex-col overflow-hidden rounded-xl border bg-panel transition-colors',
|
||||||
Active
|
isActive ? 'border-accent' : 'border-line hover:border-line-strong',
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-muted">
|
>
|
||||||
{campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e'}
|
{/* Cover band */}
|
||||||
|
<div className="relative h-24 border-b border-line bg-gradient-to-br from-accent-glow via-panel-2 to-surface-2">
|
||||||
|
<BookOpenText
|
||||||
|
aria-hidden
|
||||||
|
className="absolute right-3 top-3 h-12 w-12 text-accent/25"
|
||||||
|
strokeWidth={1.25}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-4 top-3">
|
||||||
|
<Badge tone="gold">{systemLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute right-3 bottom-3 rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-1 font-display text-lg font-semibold text-ink">{campaign.name}</h3>
|
|
||||||
{campaign.description && (
|
<div className="relative z-[1] flex flex-1 flex-col p-4">
|
||||||
<p className="mt-1 line-clamp-3 text-sm text-muted">{campaign.description}</p>
|
<h3 className="font-display text-lg font-semibold tracking-tight text-ink">{campaign.name}</h3>
|
||||||
)}
|
{campaign.description && (
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<p className="mt-1 line-clamp-3 text-sm text-muted">{campaign.description}</p>
|
||||||
<Button size="sm" variant="primary" onClick={open}>
|
)}
|
||||||
Open
|
|
||||||
</Button>
|
<hr className="gilt-rule my-4" />
|
||||||
<Button size="sm" variant="ghost" onClick={onEdit}>
|
|
||||||
Edit
|
{/* Stats footer */}
|
||||||
</Button>
|
<div className="flex items-center gap-4 text-xs text-faint">
|
||||||
<Button
|
<span className="inline-flex items-center gap-1.5">
|
||||||
size="sm"
|
<ScrollText aria-hidden className="h-4 w-4" />
|
||||||
variant="ghost"
|
{systemLabel}
|
||||||
className="ml-auto text-danger hover:bg-danger/10"
|
</span>
|
||||||
onClick={() => setConfirming(true)}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
>
|
<CalendarDays aria-hidden className="h-4 w-4" />
|
||||||
Delete
|
<span className="font-mono">{created}</span>
|
||||||
</Button>
|
</span>
|
||||||
|
<ArrowRight
|
||||||
|
aria-hidden
|
||||||
|
className="ml-auto h-4 w-4 text-accent-deep transition-transform group-hover:translate-x-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="primary" onClick={open}>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onEdit}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto text-danger hover:bg-danger/10"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||||
@@ -9,8 +10,10 @@ import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
|||||||
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
|
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
import { rollCheck } from '@/lib/useRoll';
|
import { rollCheck } from '@/lib/useRoll';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
import { Page } from '@/components/ui/Page';
|
import { Page } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge, Meter } from '@/components/ui/Codex';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
@@ -88,38 +91,61 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link to="/characters" className="text-sm text-muted hover:text-ink">
|
<Link to="/characters" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
|
||||||
← Back to characters
|
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||||
|
Back to characters
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header hero */}
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
<div className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
|
||||||
<label className="group relative h-14 w-14 shrink-0 cursor-pointer overflow-hidden rounded-full border border-line bg-surface" title="Upload portrait (also used as the map token)">
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-4 p-5">
|
||||||
{c.portrait
|
<label className="group relative h-20 w-20 shrink-0 cursor-pointer overflow-hidden rounded-2xl border border-line bg-surface-2" title="Upload portrait (also used as the map token)">
|
||||||
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
|
{c.portrait
|
||||||
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
|
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
|
||||||
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
|
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
|
||||||
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
|
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
|
||||||
</label>
|
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
|
||||||
<Input
|
</label>
|
||||||
className="max-w-xs font-display text-xl font-bold"
|
<div className="min-w-0 flex-1">
|
||||||
value={c.name}
|
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
|
||||||
aria-label="Character name"
|
<Input
|
||||||
onChange={(e) => update({ name: e.target.value })}
|
className="mt-0.5 max-w-md border-transparent bg-transparent px-0 font-display text-3xl font-semibold leading-tight tracking-tight focus-visible:border-line"
|
||||||
/>
|
value={c.name}
|
||||||
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
|
aria-label="Character name"
|
||||||
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
|
onChange={(e) => update({ name: e.target.value })}
|
||||||
</span>
|
/>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-muted">{profLabel}</span>
|
<Badge tone="gold">Level {c.level}</Badge>
|
||||||
|
<Badge>{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}</Badge>
|
||||||
|
<Badge tone="default">
|
||||||
|
<Award className="h-3 w-3" aria-hidden />
|
||||||
|
{profLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{[
|
||||||
|
{ icon: Shield, value: ac, label: 'Armor' },
|
||||||
|
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
|
||||||
|
{ icon: Footprints, value: c.speed, label: 'Speed' },
|
||||||
|
].map(({ icon: Ic, value, label }) => (
|
||||||
|
<div key={label} className="flex w-[4.5rem] flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center">
|
||||||
|
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
|
||||||
|
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
|
||||||
|
<span className="smallcaps text-[8.5px]">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
|
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
|
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="paper-grain mb-6 grid gap-4 rounded-xl border border-line bg-panel p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
||||||
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
||||||
</Labeled>
|
</Labeled>
|
||||||
@@ -150,7 +176,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
|
|
||||||
{/* Abilities */}
|
{/* Abilities */}
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<SectionTitle>Ability Scores</SectionTitle>
|
<SectionTitle>Ability Scores</SectionTitle>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
|
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,10 +184,11 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
{ABILITIES.map((a) => {
|
{ABILITIES.map((a) => {
|
||||||
const mod = sys.abilityModifier(c.abilities[a]);
|
const mod = sys.abilityModifier(c.abilities[a]);
|
||||||
return (
|
return (
|
||||||
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center">
|
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
|
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
|
||||||
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div>
|
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
|
||||||
<NumberField
|
<NumberField
|
||||||
|
className="mt-1"
|
||||||
value={c.abilities[a]}
|
value={c.abilities[a]}
|
||||||
min={1}
|
min={1}
|
||||||
max={30}
|
max={30}
|
||||||
@@ -270,14 +297,18 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
|
|||||||
}
|
}
|
||||||
setDelta(0);
|
setDelta(0);
|
||||||
};
|
};
|
||||||
|
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Hit Points</div>
|
<div className="smallcaps">Hit Points</div>
|
||||||
<div className="my-1 font-display text-2xl font-bold text-ink">
|
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
|
||||||
{c.hp.current}
|
{c.hp.current}
|
||||||
<span className="text-base text-muted"> / {c.hp.max}</span>
|
<span className="text-base text-muted"> / {c.hp.max}</span>
|
||||||
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
|
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
|
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
|
||||||
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
|
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
|
||||||
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
|
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
|
||||||
@@ -311,11 +342,16 @@ function RankRow({
|
|||||||
onRank: (r: ProficiencyRank) => void;
|
onRank: (r: ProficiencyRank) => void;
|
||||||
system: SystemId;
|
system: SystemId;
|
||||||
}) {
|
}) {
|
||||||
|
const proficient = rank !== 'untrained';
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
|
<div className="flex items-center gap-2 rounded-lg border border-line bg-panel px-3 py-1.5">
|
||||||
|
<span
|
||||||
|
className={cn('h-2 w-2 shrink-0 rounded-full', proficient ? 'bg-accent' : 'bg-line-strong')}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => rollCheck(modifier, label, { system })}
|
onClick={() => rollCheck(modifier, label, { system })}
|
||||||
className="w-9 rounded font-display text-lg font-semibold text-accent hover:bg-accent/10"
|
className="w-9 rounded font-mono font-display text-lg font-semibold text-accent-deep hover:bg-accent/10"
|
||||||
title={`Roll ${label}`}
|
title={`Roll ${label}`}
|
||||||
>
|
>
|
||||||
{formatModifier(modifier)}
|
{formatModifier(modifier)}
|
||||||
@@ -335,7 +371,7 @@ function RankRow({
|
|||||||
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
|
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted">{label}</span>
|
<span className="smallcaps mb-1 block">{label}</span>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -343,9 +379,9 @@ function Labeled({ label, children }: { label: string; children: React.ReactNode
|
|||||||
|
|
||||||
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
|
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
|
<div className="smallcaps">{label}</div>
|
||||||
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</div>
|
<div className="my-1 font-mono font-display text-3xl font-semibold text-accent-deep">{value}</div>
|
||||||
{hint && <div className="text-xs text-muted">{hint}</div>}
|
{hint && <div className="text-xs text-muted">{hint}</div>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -353,5 +389,5 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
return <h2 className="mb-2 font-display text-lg font-semibold text-ink">{children}</h2>;
|
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Heart, Shield } from 'lucide-react';
|
||||||
import { charactersRepo } from '@/lib/db/repositories';
|
import { charactersRepo } from '@/lib/db/repositories';
|
||||||
import type { Campaign, Character } from '@/lib/schemas';
|
import type { Campaign, Character } from '@/lib/schemas';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
@@ -8,6 +9,7 @@ import { pickTextFile } from '@/lib/io/file';
|
|||||||
import { useCharacters } from './hooks';
|
import { useCharacters } from './hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Avatar, Badge } from '@/components/ui/Codex';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { CreationWizard } from './builder/CreationWizard';
|
import { CreationWizard } from './builder/CreationWizard';
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow="The Roster"
|
||||||
title="Characters"
|
title="Characters"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign.name}
|
||||||
actions={
|
actions={
|
||||||
@@ -83,7 +86,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
|
<h2 className="smallcaps mb-3">{title}</h2>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
<CharacterCard key={c.id} c={c} />
|
<CharacterCard key={c.id} c={c} />
|
||||||
@@ -98,25 +101,42 @@ function CharacterCard({ c }: { c: Character }) {
|
|||||||
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
|
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-lg border border-line bg-panel transition-colors hover:border-accent/50">
|
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
|
||||||
<Link
|
<Link
|
||||||
to="/characters/$characterId"
|
to="/characters/$characterId"
|
||||||
params={{ characterId: c.id }}
|
params={{ characterId: c.id }}
|
||||||
className="block p-4"
|
className="block p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-start gap-3">
|
||||||
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
|
{c.portrait ? (
|
||||||
<span className="text-sm text-muted">Lv {c.level}</span>
|
<img
|
||||||
|
src={c.portrait}
|
||||||
|
alt=""
|
||||||
|
className="h-12 w-12 shrink-0 rounded-full border border-line object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar name={c.name} size={48} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate font-display text-lg font-semibold text-ink">{c.name}</h3>
|
||||||
|
<Badge tone="gold">Lv {c.level}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-sm text-muted">
|
||||||
|
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-sm text-muted">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
|
<span className="flex items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2 py-1.5 text-sm">
|
||||||
</p>
|
<Heart className="h-3.5 w-3.5 text-danger" aria-hidden />
|
||||||
<div className="mt-3 flex gap-4 text-sm">
|
<span className="smallcaps text-[10px]">HP</span>
|
||||||
<span className="text-muted">
|
<span className="font-mono font-semibold text-ink">{c.hp.current}/{c.hp.max}</span>
|
||||||
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted">
|
<span className="flex items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2 py-1.5 text-sm">
|
||||||
AC <span className="font-medium text-ink">{ac}</span>
|
<Shield className="h-3.5 w-3.5 text-accent-deep" aria-hidden />
|
||||||
|
<span className="smallcaps text-[10px]">AC</span>
|
||||||
|
<span className="font-mono font-semibold text-ink">{ac}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Swords, X } from 'lucide-react';
|
||||||
import type { Campaign } from '@/lib/schemas';
|
import type { Campaign } from '@/lib/schemas';
|
||||||
import { encountersRepo } from '@/lib/db/repositories';
|
import { encountersRepo } from '@/lib/db/repositories';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
@@ -6,6 +7,7 @@ import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/P
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
import { useEncounters, useEncounter } from './hooks';
|
import { useEncounters, useEncounter } from './hooks';
|
||||||
import { EncounterTracker } from './EncounterTracker';
|
import { EncounterTracker } from './EncounterTracker';
|
||||||
import { RollFeed } from '@/features/player/RollFeed';
|
import { RollFeed } from '@/features/player/RollFeed';
|
||||||
@@ -38,6 +40,7 @@ function Combat({ campaign }: { campaign: Campaign }) {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow="Roll for initiative"
|
||||||
title="Combat"
|
title="Combat"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign.name}
|
||||||
actions={
|
actions={
|
||||||
@@ -59,22 +62,32 @@ function Combat({ campaign }: { campaign: Campaign }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 lg:grid-cols-[240px_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[240px_1fr]">
|
||||||
<aside className="space-y-1">
|
<aside className="space-y-1.5">
|
||||||
|
<div className="smallcaps px-1 pb-1">Encounters</div>
|
||||||
{encounters.map((e) => (
|
{encounters.map((e) => (
|
||||||
<div
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className={
|
className={cn(
|
||||||
'group flex items-center gap-1 rounded-md border px-2 ' +
|
'group flex items-center gap-1 rounded-xl border px-2 transition-colors',
|
||||||
(validSelection?.id === e.id ? 'border-accent bg-elevated' : 'border-line')
|
validSelection?.id === e.id
|
||||||
}
|
? 'border-accent bg-accent-glow'
|
||||||
|
: 'border-line bg-panel hover:border-line-strong',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex-1 py-2 text-left text-sm"
|
className="flex flex-1 items-center gap-2.5 py-2 text-left text-sm"
|
||||||
onClick={() => setActiveEncounter(e.id)}
|
onClick={() => setActiveEncounter(e.id)}
|
||||||
>
|
>
|
||||||
<span className="block truncate text-ink">{e.name}</span>
|
<Swords
|
||||||
<span className="text-xs text-muted">
|
size={16}
|
||||||
{STATUS_LABEL[e.status]} · {e.combatants.length} combatants
|
aria-hidden
|
||||||
|
className={validSelection?.id === e.id ? 'text-accent-deep' : 'text-faint'}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-display font-medium text-ink">{e.name}</span>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{STATUS_LABEL[e.status]} · {e.combatants.length} combatants
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -85,7 +98,7 @@ function Combat({ campaign }: { campaign: Campaign }) {
|
|||||||
if (activeEncounterId === e.id) setActiveEncounter(null);
|
if (activeEncounterId === e.id) setActiveEncounter(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✕
|
<X size={15} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Dices,
|
||||||
|
Heart,
|
||||||
|
Hourglass,
|
||||||
|
Play,
|
||||||
|
ScrollText,
|
||||||
|
Shield,
|
||||||
|
Skull,
|
||||||
|
Sword,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas';
|
import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas';
|
||||||
import { encountersRepo } from '@/lib/db/repositories';
|
import { encountersRepo } from '@/lib/db/repositories';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
@@ -27,6 +43,7 @@ import { cn } from '@/lib/cn';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { Badge, Meter, Avatar } from '@/components/ui/Codex';
|
||||||
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
|
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
|
||||||
|
|
||||||
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
||||||
@@ -70,31 +87,41 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Control bar */}
|
{/* Control bar */}
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-line bg-panel p-3">
|
<div className="paper-grain mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-line bg-panel p-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-display text-lg font-semibold text-ink">{encounter.name}</div>
|
<div className="font-display text-sm italic text-accent">Encounter · {encounter.name}</div>
|
||||||
|
<div className="font-display text-lg font-semibold text-ink">Initiative</div>
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">
|
||||||
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'}
|
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'}
|
||||||
{current && isActive ? ` · ${current.name}'s turn` : ''}
|
{current && isActive ? ` · ${current.name}'s turn` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-line bg-surface-2 px-3 py-1.5">
|
||||||
|
<Hourglass size={15} aria-hidden className="text-accent-deep" />
|
||||||
|
<span className="smallcaps" style={{ fontSize: 10 }}>Round</span>
|
||||||
|
<span className="font-display text-xl font-semibold text-accent-deep">{encounter.round}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{budget && (
|
{budget && (
|
||||||
<div className="rounded-md border border-line bg-surface px-3 py-1 text-center text-xs">
|
<div className="rounded-xl border border-line bg-surface-2 px-3 py-1 text-center text-xs">
|
||||||
<div className={cn('font-display text-sm font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}>
|
<div className={cn('font-display text-sm font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}>
|
||||||
{budget.difficulty}
|
{budget.difficulty}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted">
|
<div className="font-mono text-muted">
|
||||||
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
|
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex flex-wrap gap-2">
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
{undoStack.current.length > 0 && (
|
{undoStack.current.length > 0 && (
|
||||||
<Button variant="ghost" onClick={undo} title="Undo last change">↺ Undo</Button>
|
<Button variant="ghost" onClick={undo} title="Undo last change">
|
||||||
|
<Undo2 size={15} aria-hidden /> Undo
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{encounter.combatants.length > 0 && (
|
{encounter.combatants.length > 0 && (
|
||||||
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
|
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
|
||||||
🎲 Roll all
|
<Dices size={15} aria-hidden /> Roll all
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isActive && encounter.status !== 'ended' && (
|
{!isActive && encounter.status !== 'ended' && (
|
||||||
@@ -112,10 +139,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
|
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
|
||||||
← Prev
|
<ChevronLeft size={15} aria-hidden /> Prev
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => mutate(nextTurn)}>
|
<Button variant="primary" onClick={() => mutate(nextTurn)}>
|
||||||
Next turn →
|
Next turn <ChevronRight size={15} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
||||||
End
|
End
|
||||||
@@ -138,7 +165,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
{encounter.combatants.length === 0 ? (
|
{encounter.combatants.length === 0 ? (
|
||||||
<p className="mt-6 text-sm text-muted">No combatants yet. Add characters or monsters above.</p>
|
<p className="mt-6 text-sm text-muted">No combatants yet. Add characters or monsters above.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-4 space-y-2">
|
<>
|
||||||
|
<div className="mt-4 flex items-center justify-between px-1 pb-1.5">
|
||||||
|
<span className="smallcaps">Turn order · {encounter.combatants.length} combatants</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
{encounter.combatants.map((c, idx) => (
|
{encounter.combatants.map((c, idx) => (
|
||||||
<CombatantRow
|
<CombatantRow
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -153,7 +184,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CombatLog log={encounter.log ?? []} onClear={() => mutate((e) => ({ ...e, log: [] }))} />
|
<CombatLog log={encounter.log ?? []} onClear={() => mutate((e) => ({ ...e, log: [] }))} />
|
||||||
@@ -166,15 +198,22 @@ function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; o
|
|||||||
const recent = log.slice(-40).reverse();
|
const recent = log.slice(-40).reverse();
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Combat Log</h3>
|
<h3 className="smallcaps flex items-center gap-1.5">
|
||||||
|
<ScrollText size={13} aria-hidden className="text-accent-deep" />
|
||||||
|
Combat Log
|
||||||
|
</h3>
|
||||||
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
|
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="max-h-48 space-y-0.5 overflow-auto rounded-md border border-line bg-surface p-2 text-xs">
|
<hr className="gilt-rule mb-2" />
|
||||||
|
<ul className="max-h-48 space-y-1 overflow-auto rounded-xl border border-line bg-surface-2 p-3 text-xs">
|
||||||
{recent.map((l, i) => (
|
{recent.map((l, i) => (
|
||||||
<li key={i} className="text-muted">
|
<li key={i} className="flex items-start gap-2 text-ink-soft">
|
||||||
<span className="mr-2 text-accent/70">R{l.round}</span>
|
<span className="mt-1.5 inline-block size-1.5 shrink-0 rounded-full bg-accent" aria-hidden />
|
||||||
{l.text}
|
<span className="font-display leading-snug">
|
||||||
|
<span className="mr-1.5 font-mono text-faint">R{l.round}</span>
|
||||||
|
{l.text}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -245,7 +284,7 @@ function AddCombatantBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-lg border border-line bg-panel/60 p-3">
|
<div className="space-y-2 rounded-xl border border-line bg-panel-2 p-3">
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<label className="flex-1 min-w-40 text-xs text-muted">
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||||
Name
|
Name
|
||||||
@@ -318,14 +357,22 @@ function CombatantRow({
|
|||||||
}) {
|
}) {
|
||||||
const [delta, setDelta] = useState(0);
|
const [delta, setDelta] = useState(0);
|
||||||
const dead = c.hp.current <= 0;
|
const dead = c.hp.current <= 0;
|
||||||
|
const foe = c.kind === 'monster';
|
||||||
|
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||||
|
const hpTone = ratio < 0.35 ? 'var(--app-danger)' : ratio < 0.7 ? 'var(--app-accent)' : 'var(--app-verdigris)';
|
||||||
|
const avatarTone = foe ? 'var(--app-danger)' : 'var(--app-accent)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={
|
className={cn(
|
||||||
'rounded-lg border bg-panel p-3 ' +
|
'rounded-xl border p-3 transition-colors',
|
||||||
(isCurrent ? 'border-accent ring-1 ring-accent/40' : 'border-line') +
|
isCurrent
|
||||||
(dead ? ' opacity-60' : '')
|
? foe
|
||||||
}
|
? 'border-danger bg-danger-glow'
|
||||||
|
: 'border-accent bg-accent-glow'
|
||||||
|
: 'border-line bg-panel',
|
||||||
|
dead && 'opacity-60',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@@ -333,58 +380,91 @@ function CombatantRow({
|
|||||||
<span className="mt-0.5 text-[10px] uppercase text-muted">init</span>
|
<span className="mt-0.5 text-[10px] uppercase text-muted">init</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar name={c.name} size={40} tone={avatarTone} />
|
||||||
|
{foe && (
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 grid size-4 place-items-center rounded-full border-2 border-panel bg-danger"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Skull size={9} className="text-white" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-w-32 flex-1">
|
<div className="min-w-32 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-ink">{c.name}</span>
|
<span className="font-display font-semibold text-ink">{c.name}</span>
|
||||||
<span className="rounded bg-elevated px-1.5 text-[10px] uppercase text-muted">{c.kind}</span>
|
{isCurrent && (
|
||||||
{dead && <span className="text-xs font-semibold text-danger">DOWN</span>}
|
<Badge tone={foe ? 'ember' : 'gold'}>
|
||||||
|
<Play size={11} aria-hidden /> Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span>
|
||||||
|
{dead && (
|
||||||
|
<Badge tone="ember">
|
||||||
|
<Skull size={11} aria-hidden /> DOWN
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div className="w-32">
|
||||||
|
<Meter value={c.hp.current} max={c.hp.max} tone={hpTone} height={6} />
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-faint">
|
||||||
|
{c.hp.current}/{c.hp.max}
|
||||||
|
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{c.conditions.length > 0 && (
|
{c.conditions.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
<button
|
<button
|
||||||
key={`${cond.name}-${i}`}
|
key={`${cond.name}-${i}`}
|
||||||
className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:line-through"
|
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris transition-colors hover:line-through"
|
||||||
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
|
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
|
||||||
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
||||||
>
|
>
|
||||||
{cond.name}
|
{cond.name}
|
||||||
{cond.value ? ` ${cond.value}` : ''}
|
{cond.value ? ` ${cond.value}` : ''}
|
||||||
{cond.duration ? ` (${cond.duration}r)` : ''} ✕
|
{cond.duration ? ` (${cond.duration}r)` : ''}
|
||||||
|
<X size={11} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP controls */}
|
||||||
<div className="text-center">
|
<div className="flex items-center gap-1">
|
||||||
<div className="text-sm">
|
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
||||||
<span className={dead ? 'text-danger' : 'text-ink'}>{c.hp.current}</span>
|
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
|
||||||
<span className="text-muted">/{c.hp.max}</span>
|
<Sword size={14} aria-hidden /> Dmg
|
||||||
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
|
</Button>
|
||||||
</div>
|
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
||||||
<div className="mt-1 flex items-center gap-1">
|
<Heart size={14} aria-hidden className="text-verdigris" /> Heal
|
||||||
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
</Button>
|
||||||
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
|
|
||||||
Dmg
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
|
||||||
Heal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm">
|
<div className="flex flex-col items-center">
|
||||||
<div className="text-muted">AC</div>
|
<div className="flex items-center gap-1 text-muted">
|
||||||
<NumberField className="w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} />
|
<Shield size={13} aria-hidden />
|
||||||
|
<span className="smallcaps" style={{ fontSize: 9 }}>AC</span>
|
||||||
|
</div>
|
||||||
|
<NumberField className="mt-0.5 w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up">↑</Button>
|
<Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up">
|
||||||
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down">↓</Button>
|
<ArrowUp size={15} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down">
|
||||||
|
<ArrowDown size={15} aria-hidden />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>
|
||||||
|
<X size={15} aria-hidden />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { Search, BookOpenText } from 'lucide-react';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import type { SystemId } from '@/lib/rules';
|
import type { SystemId } from '@/lib/rules';
|
||||||
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
||||||
@@ -126,17 +127,18 @@ export function CompendiumPage() {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow="The Codex"
|
||||||
title="Compendium"
|
title="Compendium"
|
||||||
subtitle="Searchable reference — bestiary, spells, items, feats, and more."
|
subtitle="Searchable reference — bestiary, spells, items, feats, and more."
|
||||||
actions={
|
actions={
|
||||||
<div className="flex gap-1 rounded-md border border-line p-0.5">
|
<div className="flex gap-0.5 rounded-lg border border-line bg-panel p-0.5">
|
||||||
{SYSTEM_OPTIONS.map((o) => (
|
{SYSTEM_OPTIONS.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.id}
|
key={o.id}
|
||||||
onClick={() => changeSystem(o.id)}
|
onClick={() => changeSystem(o.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-3 py-1 text-sm transition-colors',
|
'rounded-md px-3 py-1 text-sm transition-colors',
|
||||||
system === o.id ? 'bg-accent text-accent-ink' : 'text-muted hover:text-ink',
|
system === o.id ? 'bg-accent text-accent-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]' : 'text-muted hover:text-ink',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
@@ -153,8 +155,10 @@ export function CompendiumPage() {
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => setCategoryId(c.id)}
|
onClick={() => setCategoryId(c.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
'rounded-md border px-3 py-1.5 text-sm transition-colors',
|
||||||
c.id === category.id ? 'bg-elevated text-accent ring-1 ring-accent/40' : 'bg-panel text-muted hover:text-ink',
|
c.id === category.id
|
||||||
|
? 'border-accent bg-accent-glow font-medium text-accent-deep'
|
||||||
|
: 'border-line bg-panel text-muted hover:text-ink',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{c.label}
|
{c.label}
|
||||||
@@ -164,12 +168,20 @@ export function CompendiumPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<div className="relative">
|
||||||
value={query}
|
<Search
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
aria-hidden
|
||||||
placeholder={`Search ${category.label.toLowerCase()}…`}
|
size={16}
|
||||||
aria-label="Search compendium"
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={`Search ${category.label.toLowerCase()}…`}
|
||||||
|
aria-label="Search compendium"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{category.filters.length > 0 && (
|
{category.filters.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{category.filters.map((f) => {
|
{category.filters.map((f) => {
|
||||||
@@ -203,7 +215,7 @@ export function CompendiumPage() {
|
|||||||
>
|
>
|
||||||
{sortOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{sortOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<span className="text-xs text-muted">
|
<span className="font-mono text-xs text-faint">
|
||||||
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`}
|
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,15 +232,17 @@ export function CompendiumPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setSelected(entry)}
|
onClick={() => setSelected(entry)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm',
|
'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm transition-colors',
|
||||||
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
selected === entry
|
||||||
|
? 'border-accent bg-accent-glow'
|
||||||
|
: 'border-line bg-panel hover:border-accent/40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
{isHomebrew(entry) && <span className="shrink-0 rounded bg-accent/20 px-1 text-[10px] font-semibold uppercase text-accent">HB</span>}
|
{isHomebrew(entry) && <span className="shrink-0 rounded bg-accent-glow px-1 text-[10px] font-semibold uppercase text-accent-deep">HB</span>}
|
||||||
<span className="truncate text-ink">{entry.name}</span>
|
<span className="truncate font-display font-medium text-ink">{entry.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-xs text-muted">{isHomebrew(entry) ? 'Homebrew' : category.meta(entry)}</span>
|
<span className="shrink-0 font-mono text-xs text-faint">{isHomebrew(entry) ? 'Homebrew' : category.meta(entry)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -237,14 +251,17 @@ export function CompendiumPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-5">
|
<div className="paper-grain overflow-hidden rounded-xl border border-line border-t-4 border-t-accent bg-panel p-6">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div>
|
<div>
|
||||||
<DetailActions category={category} entry={selected} system={system} />
|
<DetailActions category={category} entry={selected} system={system} />
|
||||||
{isHomebrew(selected) ? <HomebrewDetail entry={selected} /> : category.detail(selected)}
|
{isHomebrew(selected) ? <HomebrewDetail entry={selected} /> : category.detail(selected)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">Select an entry to view details.</p>
|
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<BookOpenText aria-hidden size={28} className="text-faint" />
|
||||||
|
<p className="font-display italic text-muted">Select an entry to view details.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,7 +288,9 @@ function DetailActions({ category, entry, system }: { category: CategoryDef; ent
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActionBar({ children }: { children: React.ReactNode }) {
|
function ActionBar({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="mb-3 flex flex-wrap items-center gap-2 border-b border-line pb-3">{children}</div>;
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-line/70 pb-4">{children}</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
|
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ function actionList(value: MonsterAction[] | string | undefined): MonsterAction[
|
|||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single "Label value" line in the Codex stat block. */
|
||||||
|
function StatLine({ label, children }: { label: string; children?: React.ReactNode }) {
|
||||||
|
if (children === undefined || children === null || children === '' || children === false) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-relaxed text-ink-soft">
|
||||||
|
<span className="font-display font-bold text-danger">{label} </span>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MonsterDetail({ monster: m }: { monster: Monster }) {
|
export function MonsterDetail({ monster: m }: { monster: Monster }) {
|
||||||
const abilities: [string, number | undefined][] = [
|
const abilities: [string, number | undefined][] = [
|
||||||
['STR', m.strength],
|
['STR', m.strength],
|
||||||
@@ -16,44 +27,52 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
|
|||||||
['WIS', m.wisdom],
|
['WIS', m.wisdom],
|
||||||
['CHA', m.charisma],
|
['CHA', m.charisma],
|
||||||
];
|
];
|
||||||
|
const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', ');
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="font-display">
|
||||||
<header>
|
<header>
|
||||||
<h2 className="font-display text-2xl font-bold text-accent">{m.name}</h2>
|
<h2 className="font-display text-3xl font-semibold tracking-tight text-danger">{m.name}</h2>
|
||||||
<p className="text-sm italic text-muted">
|
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
|
||||||
{[m.size, m.type, m.alignment].filter(Boolean).join(', ')}
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
<hr className="gilt-rule my-4" />
|
||||||
<span><strong className="text-ink">AC</strong> {m.armor_class ?? '—'} {m.armor_desc ? `(${m.armor_desc})` : ''}</span>
|
|
||||||
<span><strong className="text-ink">HP</strong> {m.hit_points ?? '—'} {m.hit_dice ? `(${m.hit_dice})` : ''}</span>
|
<div className="space-y-1">
|
||||||
<span><strong className="text-ink">CR</strong> {crLabel(m)}</span>
|
<StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine>
|
||||||
{m.speed?.walk !== undefined && <span><strong className="text-ink">Speed</strong> {m.speed.walk} ft.</span>}
|
<StatLine label="Hit Points">{m.hit_points ?? '—'}{m.hit_dice ? ` (${m.hit_dice})` : ''}</StatLine>
|
||||||
|
{m.speed?.walk !== undefined && <StatLine label="Speed">{m.speed.walk} ft.</StatLine>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-6 gap-2 rounded-lg border border-line bg-surface p-3 text-center text-sm">
|
<hr className="gilt-rule my-4" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
{abilities.map(([label, score]) => (
|
{abilities.map(([label, score]) => (
|
||||||
<div key={label}>
|
<div key={label} className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-surface-2 px-1 py-2 text-center">
|
||||||
<div className="text-xs font-semibold text-muted">{label}</div>
|
<span className="smallcaps text-danger" style={{ fontSize: 9 }}>{label}</span>
|
||||||
<div className="text-ink">{score ?? '—'}</div>
|
<span className="font-display text-lg font-semibold text-ink">{score ?? '—'}</span>
|
||||||
<div className="text-xs text-accent">
|
<span className="font-mono text-xs text-muted">
|
||||||
{score !== undefined ? formatModifier(abilityModifier(score)) : ''}
|
{score !== undefined ? formatModifier(abilityModifier(score)) : ''}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && (
|
{(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && (
|
||||||
<div className="space-y-1 text-sm text-muted">
|
<>
|
||||||
{m.damage_resistances && <p><strong className="text-ink">Resistances</strong> {m.damage_resistances}</p>}
|
<hr className="gilt-rule my-4" />
|
||||||
{m.damage_immunities && <p><strong className="text-ink">Immunities</strong> {m.damage_immunities}</p>}
|
<div className="space-y-1">
|
||||||
{m.condition_immunities && <p><strong className="text-ink">Condition Immunities</strong> {m.condition_immunities}</p>}
|
<StatLine label="Resistances">{m.damage_resistances}</StatLine>
|
||||||
{m.senses && <p><strong className="text-ink">Senses</strong> {m.senses}</p>}
|
<StatLine label="Immunities">{m.damage_immunities}</StatLine>
|
||||||
{m.languages && <p><strong className="text-ink">Languages</strong> {m.languages}</p>}
|
<StatLine label="Condition Immunities">{m.condition_immunities}</StatLine>
|
||||||
</div>
|
<StatLine label="Senses">{m.senses}</StatLine>
|
||||||
|
<StatLine label="Languages">{m.languages}</StatLine>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<hr className="gilt-rule my-4" />
|
||||||
|
<StatLine label="Challenge">{crLabel(m)}</StatLine>
|
||||||
|
|
||||||
<ActionBlock title="Special Abilities" actions={actionList(m.special_abilities)} />
|
<ActionBlock title="Special Abilities" actions={actionList(m.special_abilities)} />
|
||||||
<ActionBlock title="Actions" actions={actionList(m.actions)} />
|
<ActionBlock title="Actions" actions={actionList(m.actions)} />
|
||||||
<ActionBlock title="Reactions" actions={actionList(m.reactions)} />
|
<ActionBlock title="Reactions" actions={actionList(m.reactions)} />
|
||||||
@@ -65,12 +84,12 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
|
|||||||
function ActionBlock({ title, actions }: { title: string; actions: MonsterAction[] }) {
|
function ActionBlock({ title, actions }: { title: string; actions: MonsterAction[] }) {
|
||||||
if (actions.length === 0) return null;
|
if (actions.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="mt-5">
|
||||||
<h3 className="mb-1 border-b border-line pb-1 font-display text-lg font-semibold text-ink">{title}</h3>
|
<h3 className="mb-2 border-b-2 border-danger pb-1 font-display text-lg font-semibold text-danger">{title}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{actions.map((a, i) => (
|
{actions.map((a, i) => (
|
||||||
<p key={`${a.name}-${i}`} className="text-sm text-muted">
|
<p key={`${a.name}-${i}`} className="text-sm leading-relaxed text-ink-soft">
|
||||||
<strong className="text-ink">{a.name}.</strong> {a.desc}
|
<strong className="italic text-ink">{a.name}.</strong> {a.desc}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,42 +8,70 @@ import type {
|
|||||||
CompendiumEntry,
|
CompendiumEntry,
|
||||||
} from '@/lib/compendium/types';
|
} from '@/lib/compendium/types';
|
||||||
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||||
|
import { Badge } from '@/components/ui/Codex';
|
||||||
|
|
||||||
const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||||
|
|
||||||
function ClassRow({ label, value }: { label: string; value: string }) {
|
/** Editorial header shared by every detail renderer: Spectral title + italic sub + gilt rule. */
|
||||||
return <div className="flex gap-2"><span className="w-28 shrink-0 text-xs uppercase tracking-wide text-muted">{label}</span><span className="text-ink">{value}</span></div>;
|
function Header({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
tone = 'text-accent-deep',
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | undefined;
|
||||||
|
tone?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<h2 className={`font-display text-3xl font-semibold tracking-tight ${tone}`}>{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClassDetail({ c }: { c: RulesetClass }) {
|
/** A sunken fact well: smallcaps label over a serif value. */
|
||||||
|
function FactWell({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="flex flex-col gap-0.5 rounded-xl border border-line bg-surface-2 px-3 py-2">
|
||||||
{c.description && <p className="text-muted">{c.description}</p>}
|
<span className="smallcaps" style={{ fontSize: 9 }}>{label}</span>
|
||||||
<ClassRow label={c.system === 'pf2e' ? 'HP / level' : 'Hit die'} value={c.system === 'pf2e' ? String(c.hitDie) : `d${c.hitDie}`} />
|
<span className="font-display text-sm font-semibold text-ink">{value}</span>
|
||||||
{c.keyAbilities.length > 0 && <ClassRow label="Key ability" value={c.keyAbilities.map(cap).join(' or ')} />}
|
|
||||||
{c.saves.length > 0 && <ClassRow label="Saves" value={c.saves.map((s) => (c.saveRanks?.[s] ? `${cap(s)} (${c.saveRanks[s]})` : cap(s))).join(', ')} />}
|
|
||||||
{c.perception && <ClassRow label="Perception" value={c.perception} />}
|
|
||||||
<ClassRow label="Skills" value={`${c.skillCount}${c.skillChoices.length ? ` — from ${c.skillChoices.join(', ')}` : ''}`} />
|
|
||||||
{c.caster !== 'none' && <ClassRow label="Spellcasting" value={cap(c.caster)} />}
|
|
||||||
{c.weapons.length > 0 && <ClassRow label="Weapons" value={c.weapons.map(cap).join(', ')} />}
|
|
||||||
{c.armor.length > 0 && <ClassRow label="Armor" value={c.armor.map(cap).join(', ')} />}
|
|
||||||
{c.subclasses.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{c.system === 'pf2e' ? 'Options' : 'Subclasses'}</div>
|
|
||||||
<ul className="list-disc space-y-0.5 pl-5 text-ink">{c.subclasses.slice(0, 16).map((s) => <li key={s.name}>{s.name}</li>)}</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="pt-1 text-xs text-muted">Source: {c.source}{c.license ? ` · ${c.license}` : ''}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ title, subtitle }: { title: string; subtitle?: string | undefined }) {
|
function ClassRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<header>
|
<div className="flex gap-2">
|
||||||
<h2 className="font-display text-2xl font-bold text-accent">{title}</h2>
|
<span className="smallcaps w-28 shrink-0">{label}</span>
|
||||||
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
|
<span className="text-ink-soft">{value}</span>
|
||||||
</header>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassDetail({ c }: { c: RulesetClass }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
{c.description && <p className="font-display leading-relaxed text-ink-soft">{c.description}</p>}
|
||||||
|
<hr className="gilt-rule my-1" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ClassRow label={c.system === 'pf2e' ? 'HP / level' : 'Hit die'} value={c.system === 'pf2e' ? String(c.hitDie) : `d${c.hitDie}`} />
|
||||||
|
{c.keyAbilities.length > 0 && <ClassRow label="Key ability" value={c.keyAbilities.map(cap).join(' or ')} />}
|
||||||
|
{c.saves.length > 0 && <ClassRow label="Saves" value={c.saves.map((s) => (c.saveRanks?.[s] ? `${cap(s)} (${c.saveRanks[s]})` : cap(s))).join(', ')} />}
|
||||||
|
{c.perception && <ClassRow label="Perception" value={c.perception} />}
|
||||||
|
<ClassRow label="Skills" value={`${c.skillCount}${c.skillChoices.length ? ` — from ${c.skillChoices.join(', ')}` : ''}`} />
|
||||||
|
{c.caster !== 'none' && <ClassRow label="Spellcasting" value={cap(c.caster)} />}
|
||||||
|
{c.weapons.length > 0 && <ClassRow label="Weapons" value={c.weapons.map(cap).join(', ')} />}
|
||||||
|
{c.armor.length > 0 && <ClassRow label="Armor" value={c.armor.map(cap).join(', ')} />}
|
||||||
|
</div>
|
||||||
|
{c.subclasses.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="smallcaps mb-2">{c.system === 'pf2e' ? 'Options' : 'Subclasses'}</div>
|
||||||
|
<ul className="list-disc space-y-0.5 pl-5 text-ink-soft">{c.subclasses.slice(0, 16).map((s) => <li key={s.name}>{s.name}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="pt-1 text-xs text-faint">Source: {c.source}{c.license ? ` · ${c.license}` : ''}</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +80,7 @@ function Traits({ traits }: { traits?: string[] | undefined }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{traits.map((t) => (
|
{traits.map((t) => (
|
||||||
<span key={t} className="rounded bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
|
<Badge key={t}>{t}</Badge>
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -63,17 +89,22 @@ function Traits({ traits }: { traits?: string[] | undefined }) {
|
|||||||
export function Spell5eDetail({ entry: s }: { entry: Spell }) {
|
export function Spell5eDetail({ entry: s }: { entry: Spell }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={s.name} subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`} />
|
<Header
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
title={s.name}
|
||||||
<span><strong className="text-ink">Casting Time</strong> {s.casting_time}</span>
|
subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`}
|
||||||
<span><strong className="text-ink">Range</strong> {s.range}</span>
|
tone="text-info"
|
||||||
<span><strong className="text-ink">Duration</strong> {s.duration}</span>
|
/>
|
||||||
<span><strong className="text-ink">Components</strong> {s.components}</span>
|
<hr className="gilt-rule my-1" />
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<FactWell label="Casting Time" value={s.casting_time} />
|
||||||
|
<FactWell label="Range" value={s.range} />
|
||||||
|
<FactWell label="Duration" value={s.duration} />
|
||||||
|
<FactWell label="Components" value={s.components} />
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap text-sm text-ink">{s.desc}</p>
|
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{s.desc}</p>
|
||||||
{s.higher_level && (
|
{s.higher_level && (
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted">
|
<p className="whitespace-pre-wrap text-sm text-muted">
|
||||||
<strong className="text-ink">At Higher Levels.</strong> {s.higher_level}
|
<strong className="italic text-ink">At Higher Levels.</strong> {s.higher_level}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +121,8 @@ export function Item5eDetail({ entry: item }: { entry: MagicItem }) {
|
|||||||
(item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : '')
|
(item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : '')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="whitespace-pre-wrap text-sm text-ink">{item.desc}</p>
|
<hr className="gilt-rule my-1" />
|
||||||
|
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,10 +131,11 @@ export function Weapon5eDetail({ entry: w }: { entry: Weapon5e }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={w.name} subtitle={w.category} />
|
<Header title={w.name} subtitle={w.category} />
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
<hr className="gilt-rule my-1" />
|
||||||
<span><strong className="text-ink">Damage</strong> {w.damage_dice} {w.damage_type}</span>
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
<span><strong className="text-ink">Cost</strong> {w.cost ?? '—'}</span>
|
<FactWell label="Damage" value={`${w.damage_dice} ${w.damage_type}`} />
|
||||||
<span><strong className="text-ink">Weight</strong> {w.weight ?? '—'}</span>
|
<FactWell label="Cost" value={w.cost ?? '—'} />
|
||||||
|
<FactWell label="Weight" value={w.weight ?? '—'} />
|
||||||
</div>
|
</div>
|
||||||
{w.properties && w.properties.length > 0 && (
|
{w.properties && w.properties.length > 0 && (
|
||||||
<p className="text-sm text-muted"><strong className="text-ink">Properties</strong> {w.properties.join(', ')}</p>
|
<p className="text-sm text-muted"><strong className="text-ink">Properties</strong> {w.properties.join(', ')}</p>
|
||||||
@@ -115,11 +148,12 @@ export function Armor5eDetail({ entry: a }: { entry: Armor5e }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={a.name} subtitle={`${a.type ?? ''} armor`} />
|
<Header title={a.name} subtitle={`${a.type ?? ''} armor`} />
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
<hr className="gilt-rule my-1" />
|
||||||
<span><strong className="text-ink">AC</strong> {a.ac}</span>
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
<span><strong className="text-ink">Cost</strong> {a.cost ?? '—'}</span>
|
<FactWell label="AC" value={a.ac} />
|
||||||
<span><strong className="text-ink">Weight</strong> {a.weight ?? '—'} lb</span>
|
<FactWell label="Cost" value={a.cost ?? '—'} />
|
||||||
<span><strong className="text-ink">Stealth</strong> {a.stealthPenalty ? 'Disadvantage' : '—'}</span>
|
<FactWell label="Weight" value={`${a.weight ?? '—'} lb`} />
|
||||||
|
<FactWell label="Stealth" value={a.stealthPenalty ? 'Disadvantage' : '—'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -129,7 +163,8 @@ export function Feat5eDetail({ entry: f }: { entry: Feat5e }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={f.name} subtitle="Feat" />
|
<Header title={f.name} subtitle="Feat" />
|
||||||
<p className="whitespace-pre-wrap text-sm text-ink">{f.description}</p>
|
<hr className="gilt-rule my-1" />
|
||||||
|
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{f.description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,7 +173,8 @@ export function Condition5eDetail({ entry: c }: { entry: Condition5e }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={c.name} subtitle="Condition" />
|
<Header title={c.name} subtitle="Condition" />
|
||||||
{c.description && <p className="text-sm text-ink">{c.description}</p>}
|
<hr className="gilt-rule my-1" />
|
||||||
|
{c.description && <p className="font-display text-[15px] leading-relaxed text-ink-soft">{c.description}</p>}
|
||||||
{c.effects && c.effects.length > 0 && (
|
{c.effects && c.effects.length > 0 && (
|
||||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted">
|
<ul className="list-disc space-y-1 pl-5 text-sm text-muted">
|
||||||
{c.effects.map((e, i) => <li key={i}>{e}</li>)}
|
{c.effects.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
@@ -156,14 +192,15 @@ export function HomebrewDetail({ entry }: { entry: CompendiumEntry }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={entry.name} subtitle={`Homebrew ${kind}`} />
|
<Header title={entry.name} subtitle={`Homebrew ${kind}`} />
|
||||||
|
<hr className="gilt-rule my-1" />
|
||||||
{facts.length > 0 && (
|
{facts.length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{facts.map(([k, v]) => (
|
{facts.map(([k, v]) => (
|
||||||
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span>
|
<FactWell key={k} label={k} value={String(v)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{entry.description ? <p className="whitespace-pre-wrap text-sm text-ink">{String(entry.description)}</p> : null}
|
{entry.description ? <p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{String(entry.description)}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,14 +228,15 @@ export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Header title={entry.name} subtitle={(entry.type as string | undefined) ?? undefined} />
|
<Header title={entry.name} subtitle={(entry.type as string | undefined) ?? undefined} />
|
||||||
<Traits traits={traits} />
|
<Traits traits={traits} />
|
||||||
|
<hr className="gilt-rule my-1" />
|
||||||
{shown.length > 0 && (
|
{shown.length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{shown.map(([k, v]) => (
|
{shown.map(([k, v]) => (
|
||||||
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span>
|
<FactWell key={k} label={k} value={String(v)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="whitespace-pre-line text-sm text-ink">{text}</p>
|
<p className="whitespace-pre-line font-display text-[15px] leading-relaxed text-ink-soft">{text}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-48
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { Dices, Sparkles, Skull, History as HistoryIcon } from 'lucide-react';
|
||||||
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { diceRepo } from '@/lib/db/repositories';
|
import { diceRepo } from '@/lib/db/repositories';
|
||||||
@@ -11,9 +12,25 @@ import { useSessionStore } from '@/stores/sessionStore';
|
|||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Codex';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
|
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
|
||||||
|
const DIE_SIDES: Record<string, number> = { d4: 4, d6: 6, d8: 8, d10: 10, d12: 12, d20: 20, d100: 100 };
|
||||||
|
|
||||||
|
/** A faceted-die glyph with the side count inscribed — used for the quick-roll pool. */
|
||||||
|
function DieGlyph({ sides, size = 44 }: { sides: number; size?: number }) {
|
||||||
|
return (
|
||||||
|
<span className="relative grid place-items-center text-line-strong transition-colors group-hover:text-accent" style={{ width: size, height: size }}>
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinejoin="round" aria-hidden>
|
||||||
|
<polygon points="12,2 21,7 21,17 12,22 3,17 3,7" />
|
||||||
|
</svg>
|
||||||
|
<span className="absolute inset-0 grid place-items-center pt-px font-mono text-[11px] font-semibold text-ink transition-colors group-hover:text-accent-deep">
|
||||||
|
{sides}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DicePage() {
|
export function DicePage() {
|
||||||
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
|
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
|
||||||
@@ -59,6 +76,7 @@ export function DicePage() {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow="Cast the bones"
|
||||||
title="Dice"
|
title="Dice"
|
||||||
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
|
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
|
||||||
actions={
|
actions={
|
||||||
@@ -72,6 +90,46 @@ export function DicePage() {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
<div>
|
<div>
|
||||||
|
{/* Result stage */}
|
||||||
|
{last ? (() => {
|
||||||
|
const nat = naturalD20(last);
|
||||||
|
const crit = nat === 20 ? 'crit' : nat === 1 ? 'fumble' : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'paper-grain mb-6 overflow-hidden rounded-xl border bg-panel p-7 text-center',
|
||||||
|
crit === 'crit' ? 'animate-crit-glow border-accent' : crit === 'fumble' ? 'border-danger' : 'border-line',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-xs text-faint">{last.expression}</div>
|
||||||
|
{crit === 'crit' && (
|
||||||
|
<div className="mt-2 flex justify-center">
|
||||||
|
<Badge tone="verdigris">
|
||||||
|
<Sparkles size={12} aria-hidden /> Critical!
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{crit === 'fumble' && (
|
||||||
|
<div className="mt-2 flex justify-center">
|
||||||
|
<Badge tone="ember">
|
||||||
|
<Skull size={12} aria-hidden /> Fumble!
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AnimatedTotal key={rollCount} result={last} />
|
||||||
|
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
Rolled {last.expression}: {last.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : (
|
||||||
|
<div className="paper-grain mb-6 grid min-h-[140px] place-items-center rounded-xl border border-line bg-panel p-7 text-center">
|
||||||
|
<p className="font-display text-lg italic text-muted">Choose your dice, then cast.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expression + Roll */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={expr}
|
value={expr}
|
||||||
@@ -83,34 +141,56 @@ export function DicePage() {
|
|||||||
aria-label="Dice expression"
|
aria-label="Dice expression"
|
||||||
/>
|
/>
|
||||||
<Button variant="primary" onClick={() => void roll(expr)}>
|
<Button variant="primary" onClick={() => void roll(expr)}>
|
||||||
|
<Dices size={16} aria-hidden />
|
||||||
Roll
|
Roll
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
{/* Die pool */}
|
||||||
|
<div className="mt-4 flex flex-wrap items-end justify-center gap-3 sm:justify-start">
|
||||||
{DICE.map((d) => (
|
{DICE.map((d) => (
|
||||||
<Button key={d} size="sm" onClick={() => void roll(`1${d}`)}>
|
<button
|
||||||
{d}
|
key={d}
|
||||||
</Button>
|
type="button"
|
||||||
|
onClick={() => void roll(`1${d}`)}
|
||||||
|
aria-label={d}
|
||||||
|
className="group grid place-items-center rounded-lg p-1 transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||||
|
>
|
||||||
|
<DieGlyph sides={DIE_SIDES[d] ?? 20} />
|
||||||
|
<span className="mt-0.5 font-mono text-[11px] text-muted transition-colors group-hover:text-accent-deep">{d}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
|
||||||
variant={mode === 'advantage' ? 'primary' : 'secondary'}
|
{/* Advantage / Disadvantage segmented toggle + secret */}
|
||||||
aria-pressed={mode === 'advantage'}
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
onClick={() => toggleMode('advantage')}
|
<span className="smallcaps">Mode</span>
|
||||||
title="Toggle advantage — rolls the next die twice, keep highest"
|
<div className="inline-flex gap-0.5 rounded-lg border border-line bg-surface-2 p-0.5" role="group" aria-label="Roll mode">
|
||||||
>
|
<button
|
||||||
Advantage
|
type="button"
|
||||||
</Button>
|
aria-pressed={mode === 'advantage'}
|
||||||
<Button
|
onClick={() => toggleMode('advantage')}
|
||||||
size="sm"
|
title="Toggle advantage — rolls the next die twice, keep highest"
|
||||||
variant={mode === 'disadvantage' ? 'primary' : 'secondary'}
|
className={cn(
|
||||||
aria-pressed={mode === 'disadvantage'}
|
'whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
onClick={() => toggleMode('disadvantage')}
|
mode === 'advantage' ? 'bg-panel text-ink shadow-sm' : 'text-muted hover:text-ink',
|
||||||
title="Toggle disadvantage — rolls the next die twice, keep lowest"
|
)}
|
||||||
>
|
>
|
||||||
Disadvantage
|
Advantage
|
||||||
</Button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={mode === 'disadvantage'}
|
||||||
|
onClick={() => toggleMode('disadvantage')}
|
||||||
|
title="Toggle disadvantage — rolls the next die twice, keep lowest"
|
||||||
|
className={cn(
|
||||||
|
'whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
mode === 'disadvantage' ? 'bg-panel text-ink shadow-sm' : 'text-muted hover:text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Disadvantage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{isGm && (
|
{isGm && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -133,9 +213,9 @@ export function DicePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Saved rolls (macros) */}
|
{/* Saved rolls (macros) */}
|
||||||
<div className="mt-4">
|
<div className="mt-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Saved rolls</span>
|
<span className="smallcaps">Saved rolls</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -146,17 +226,17 @@ export function DicePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{macros.length > 0 && (
|
{macros.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{macros.map((m) => (
|
{macros.map((m) => (
|
||||||
<span key={m.id} className="inline-flex items-center overflow-hidden rounded-md border border-line bg-panel">
|
<span key={m.id} className="inline-flex items-center overflow-hidden rounded-md border border-line bg-panel">
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 text-sm text-ink hover:bg-elevated"
|
className="px-2.5 py-1 font-mono text-sm text-ink hover:bg-elevated"
|
||||||
onClick={() => { setExpr(m.expression); void roll(m.expression, m.label !== m.expression ? m.label : ''); }}
|
onClick={() => { setExpr(m.expression); void roll(m.expression, m.label !== m.expression ? m.label : ''); }}
|
||||||
>
|
>
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-1.5 text-muted hover:text-danger"
|
className="px-1.5 py-1 text-muted hover:text-danger"
|
||||||
onClick={() => removeMacro(macroKey, m.id)}
|
onClick={() => removeMacro(macroKey, m.id)}
|
||||||
aria-label={`Remove ${m.label}`}
|
aria-label={`Remove ${m.label}`}
|
||||||
>
|
>
|
||||||
@@ -169,26 +249,15 @@ export function DicePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
|
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
{last && (() => {
|
|
||||||
const nat = naturalD20(last);
|
|
||||||
const crit = nat === 20 ? 'crit' : nat === 1 ? 'fumble' : null;
|
|
||||||
return (
|
|
||||||
<div className={cn('mt-6 rounded-lg border bg-panel p-6 text-center', crit === 'crit' ? 'animate-crit-glow border-warning' : crit === 'fumble' ? 'border-danger' : 'border-line')}>
|
|
||||||
{crit === 'crit' && <div className="mb-1 text-sm font-bold uppercase tracking-wide text-warning">✦ Critical! ✦</div>}
|
|
||||||
{crit === 'fumble' && <div className="mb-1 text-sm font-bold uppercase tracking-wide text-danger">✦ Fumble! ✦</div>}
|
|
||||||
<AnimatedTotal key={rollCount} result={last} />
|
|
||||||
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
|
|
||||||
<span className="sr-only" aria-live="polite">
|
|
||||||
Rolled {last.expression}: {last.total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* History rail */}
|
||||||
<aside>
|
<aside>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">History</h2>
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<HistoryIcon size={14} className="text-accent" aria-hidden />
|
||||||
|
<h2 className="smallcaps">History</h2>
|
||||||
|
</div>
|
||||||
|
<hr className="gilt-rule mb-3" />
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No rolls yet.</p>
|
<p className="text-sm text-muted">No rolls yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -196,13 +265,13 @@ export function DicePage() {
|
|||||||
{history.map((h) => (
|
{history.map((h) => (
|
||||||
<li
|
<li
|
||||||
key={h.id}
|
key={h.id}
|
||||||
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm"
|
className="flex items-center justify-between gap-3 rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="text-muted">
|
<span className="min-w-0 truncate text-muted">
|
||||||
{h.label ? `${h.label}: ` : ''}
|
{h.label ? `${h.label}: ` : ''}
|
||||||
<span className="font-mono">{h.expression}</span>
|
<span className="font-mono">{h.expression}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-lg font-semibold text-ink">{h.total}</span>
|
<span className="shrink-0 font-display text-lg font-semibold text-accent-deep">{h.total}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -243,7 +312,7 @@ function AnimatedTotal({ result }: { result: RollResult }) {
|
|||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-display text-6xl font-bold text-accent tabular-nums',
|
'mt-2 font-display text-7xl font-bold text-accent tabular-nums',
|
||||||
rolling ? 'animate-dice-rolling opacity-80' : 'animate-dice-settle',
|
rolling ? 'animate-dice-rolling opacity-80' : 'animate-dice-settle',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { RadioTower, X } from 'lucide-react';
|
||||||
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
|
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
|
||||||
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
||||||
import { charactersRepo } from '@/lib/db/repositories';
|
import { charactersRepo } from '@/lib/db/repositories';
|
||||||
@@ -43,25 +44,26 @@ export function SessionControl() {
|
|||||||
|
|
||||||
const link = joinCode ? `${location.origin}/play?room=${joinCode}` : '';
|
const link = joinCode ? `${location.origin}/play?room=${joinCode}` : '';
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center gap-1 rounded-md border border-accent/40 bg-accent/5 px-2 py-1 text-xs">
|
<div className="relative flex items-center gap-1.5 rounded-lg border border-accent/50 bg-accent-glow px-2 py-1 text-xs">
|
||||||
<span className="text-muted">{status === 'connected' ? 'Live' : status}:</span>
|
<RadioTower size={14} className="text-accent-deep" aria-hidden />
|
||||||
|
<span className="smallcaps text-[9px] text-muted">{status === 'connected' ? 'Live' : status}</span>
|
||||||
<button
|
<button
|
||||||
data-testid="join-code"
|
data-testid="join-code"
|
||||||
className="font-mono font-semibold text-accent"
|
className="font-mono font-semibold tracking-wider text-accent-deep"
|
||||||
title="Copy player link"
|
title="Copy player link"
|
||||||
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
|
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
|
||||||
>{joinCode}{copied ? ' ✓' : ''}</button>
|
>{joinCode}{copied ? ' ✓' : ''}</button>
|
||||||
|
|
||||||
{seatRequests.length > 0 && (
|
{seatRequests.length > 0 && (
|
||||||
<button data-testid="seat-requests" className="ml-1 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 text-[10px] font-bold text-white"
|
<button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
|
||||||
title="Pending seat requests" onClick={() => setShowSeats((v) => !v)}>{seatRequests.length}</button>
|
title="Pending seat requests" onClick={() => setShowSeats((v) => !v)}>{seatRequests.length}</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button size="sm" variant="ghost" className="h-6 px-1 text-danger" onClick={() => stopSession()} title="Stop session">✕</Button>
|
<Button size="sm" variant="ghost" className="h-6 w-6 px-0 text-danger" onClick={() => stopSession()} title="Stop session" aria-label="Stop session"><X size={14} aria-hidden /></Button>
|
||||||
|
|
||||||
{showSeats && seatRequests.length > 0 && (
|
{showSeats && seatRequests.length > 0 && (
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-72 rounded-lg border border-line bg-panel p-2 shadow-lg">
|
<div className="paper-grain absolute right-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-line-strong bg-panel p-2 shadow-lg">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Seat requests</div>
|
<div className="smallcaps mb-1.5 px-1 text-muted">Seat requests</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{seatRequests.map((r) => <SeatRequestRow key={r.playerId} req={r} />)}
|
{seatRequests.map((r) => <SeatRequestRow key={r.playerId} req={r} />)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -87,8 +89,8 @@ function SeatRequestRow({ req }: { req: SeatRequest }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-md border border-line bg-surface p-2 text-sm">
|
<li className="rounded-lg border border-line bg-surface-2 p-2 text-sm">
|
||||||
<div className="mb-1 text-ink">Player wants <span className="font-semibold">{name}</span></div>
|
<div className="mb-1.5 text-ink">Player wants <span className="font-display font-semibold text-ink">{name}</span></div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Button size="sm" variant="primary" onClick={() => void grant(false)}>Grant</Button>
|
<Button size="sm" variant="primary" onClick={() => void grant(false)}>Grant</Button>
|
||||||
{req.offlineSnapshot && <Button size="sm" variant="secondary" onClick={() => void grant(true)}>Apply offline & grant</Button>}
|
{req.offlineSnapshot && <Button size="sm" variant="secondary" onClick={() => void grant(true)}>Apply offline & grant</Button>}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { Mail, Eye, Map as MapIcon, Users, Swords } from 'lucide-react';
|
||||||
import type { Snapshot } from '@/lib/sync/messages';
|
import type { Snapshot } from '@/lib/sync/messages';
|
||||||
import type { PlayerMap } from '@/lib/map';
|
import type { PlayerMap } from '@/lib/map';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
import { EmptyState } from '@/components/ui/Page';
|
import { EmptyState } from '@/components/ui/Page';
|
||||||
|
import { Meter, Badge } from '@/components/ui/Codex';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { PlayerMapView } from './PlayerMapView';
|
import { PlayerMapView } from './PlayerMapView';
|
||||||
|
|
||||||
@@ -13,8 +15,10 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{privateHandout && (
|
{privateHandout && (
|
||||||
<section className="mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
|
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-info">📨 Just for you</div>
|
<div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
|
||||||
|
<Mail size={13} aria-hidden /> 📨 Just for you
|
||||||
|
</div>
|
||||||
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
|
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
|
||||||
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
|
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
|
||||||
{privateHandout.image && <img src={privateHandout.image} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
|
{privateHandout.image && <img src={privateHandout.image} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
|
||||||
@@ -22,8 +26,11 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{handout && (
|
{handout && (
|
||||||
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
|
<section className="mb-6 rounded-xl border border-accent/50 bg-accent-glow p-4">
|
||||||
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<h2 className="font-display text-2xl font-bold text-accent-deep">{handout.title || 'Handout'}</h2>
|
||||||
|
<Badge tone="gold"><Eye size={12} aria-hidden /> From the GM</Badge>
|
||||||
|
</div>
|
||||||
{handout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{handout.body}</p>}
|
{handout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{handout.body}</p>}
|
||||||
{handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
|
{handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
|
||||||
</section>
|
</section>
|
||||||
@@ -31,14 +38,18 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
|
|
||||||
{map && (
|
{map && (
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
|
<h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
|
||||||
|
<MapIcon size={13} aria-hidden /> Battle map
|
||||||
|
</h2>
|
||||||
<PlayerMapView map={map as PlayerMap} image={image} images={images} viewportHeight="65vh" />
|
<PlayerMapView map={map as PlayerMap} image={image} images={images} viewportHeight="65vh" />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2>
|
<h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
|
||||||
|
<Users size={13} aria-hidden /> Party
|
||||||
|
</h2>
|
||||||
{party.length === 0 ? (
|
{party.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No player characters.</p>
|
<p className="text-sm text-muted">No player characters.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -46,19 +57,20 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
{party.map((c) => {
|
{party.map((c) => {
|
||||||
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
|
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
|
||||||
const portrait = c.imageId ? images?.[c.imageId] : undefined;
|
const portrait = c.imageId ? images?.[c.imageId] : undefined;
|
||||||
|
const hpTone = pct > 50 ? 'var(--app-success)' : pct > 0 ? 'var(--app-warning)' : 'var(--app-danger)';
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
|
<div key={c.id} className="rounded-xl border border-line bg-panel p-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{portrait && <img src={portrait} alt="" className="h-9 w-9 shrink-0 rounded-full object-cover" />}
|
{portrait && <img src={portrait} alt="" className="h-9 w-9 shrink-0 rounded-full border border-line object-cover" />}
|
||||||
<span className="truncate font-display text-lg font-semibold text-ink">{c.name}</span>
|
<span className="truncate font-display text-lg font-semibold text-ink">{c.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-sm text-muted">Lv {c.level} · AC {c.ac}</span>
|
<span className="shrink-0 font-mono text-xs text-muted">Lv {c.level} · AC {c.ac}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
|
<div className="mt-2">
|
||||||
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
|
<Meter value={c.hp.current} max={c.hp.max} tone={hpTone} height={10} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
<div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
||||||
{c.conditions.length > 0 && (
|
{c.conditions.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
@@ -74,24 +86,24 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
|
<h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
|
||||||
Initiative {encounter ? `· Round ${encounter.round}` : ''}
|
<Swords size={13} aria-hidden /> Initiative {encounter ? `· Round ${encounter.round}` : ''}
|
||||||
</h2>
|
</h2>
|
||||||
{!encounter ? (
|
{!encounter ? (
|
||||||
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
|
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
|
||||||
) : (
|
) : (
|
||||||
<ol className="space-y-1">
|
<ol className="space-y-1">
|
||||||
{encounter.combatants.map((c) => (
|
{encounter.combatants.map((c) => (
|
||||||
<li key={c.id} className={cn('flex items-center gap-3 rounded-md border px-3 py-2', c.isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
<li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
||||||
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
|
<span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
||||||
<span className="text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
{c.hp ? (
|
{c.hp ? (
|
||||||
<span className="shrink-0 text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
|
<span className="shrink-0 font-mono text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
|
||||||
) : c.status ? (
|
) : c.status ? (
|
||||||
<span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span>
|
<span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
|
|||||||
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
|
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="mb-6 flex items-end justify-between">
|
<div className="mb-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<h1 className="font-display text-3xl font-bold text-accent">{title}</h1>
|
<div>
|
||||||
<p className="text-sm text-muted">{subtitle}</p>
|
<div className="font-display text-sm italic text-accent">The table is live</div>
|
||||||
|
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
<hr className="gilt-rule mt-3" />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { UserPlus } from 'lucide-react';
|
||||||
import type { Snapshot } from '@/lib/sync/messages';
|
import type { Snapshot } from '@/lib/sync/messages';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { EmptyState } from '@/components/ui/Page';
|
import { EmptyState } from '@/components/ui/Page';
|
||||||
|
import { Avatar, Badge } from '@/components/ui/Codex';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shown to a joined player before they control a character: pick "you" from the
|
* Shown to a joined player before they control a character: pick "you" from the
|
||||||
@@ -18,17 +20,21 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
|
|||||||
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
|
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="mb-6">
|
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Which character is yours?</h2>
|
<div className="font-display text-sm italic text-accent">Claim your seat</div>
|
||||||
<p className="mb-3 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
|
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
|
||||||
|
<hr className="gilt-rule my-3" />
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{snapshot.party.map((c) => {
|
{snapshot.party.map((c) => {
|
||||||
const hasOffline = localChars.has(c.id);
|
const hasOffline = localChars.has(c.id);
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="flex items-center gap-3 rounded-lg border border-line bg-panel p-3">
|
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
||||||
<div className="flex-1">
|
<Avatar name={c.name} size={40} />
|
||||||
<div className="font-display text-lg font-semibold text-ink">{c.name}</div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP{hasOffline ? ' · offline edits ready' : ''}</div>
|
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
||||||
|
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div>
|
||||||
|
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
|
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
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 { useUiStore, type Theme } from '@/stores/uiStore';
|
||||||
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
|
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
|
||||||
import { pickTextFile } from '@/lib/io/file';
|
import { pickTextFile } from '@/lib/io/file';
|
||||||
@@ -13,6 +25,43 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { cn } from '@/lib/cn';
|
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() {
|
export function SettingsPage() {
|
||||||
const theme = useUiStore((s) => s.theme);
|
const theme = useUiStore((s) => s.theme);
|
||||||
const setTheme = useUiStore((s) => s.setTheme);
|
const setTheme = useUiStore((s) => s.setTheme);
|
||||||
@@ -44,50 +93,61 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader title="Settings" subtitle="Appearance, backups, and data." />
|
<PageHeader eyebrow="Preferences" title="Settings" subtitle="Appearance, backups, and data." />
|
||||||
|
|
||||||
<section className="mb-8">
|
<div className="space-y-6">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</h2>
|
<SettingsCard label="Appearance">
|
||||||
<div className="flex gap-2">
|
<SettingsRow
|
||||||
{(['dark', 'light'] as Theme[]).map((t) => (
|
icon={theme === 'dark' ? Moon : Sun}
|
||||||
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} onClick={() => setTheme(t)} className="capitalize">
|
title="Theme"
|
||||||
{t}
|
desc="Parchment by day, candlelight by night."
|
||||||
</Button>
|
>
|
||||||
))}
|
{(['dark', 'light'] as Theme[]).map((t) => (
|
||||||
</div>
|
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} size="sm" onClick={() => setTheme(t)} className="capitalize">
|
||||||
</section>
|
{t}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
<section className="mb-8">
|
<SettingsCard label="Data">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data</h2>
|
<SettingsRow icon={Download} title="Export backup" desc="Download a single JSON file with every campaign and all of its data.">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Button variant="secondary" size="sm" onClick={() => void exportBackup()}>Export backup</Button>
|
||||||
<Button variant="secondary" onClick={() => void exportBackup()}>Export backup</Button>
|
</SettingsRow>
|
||||||
<Button variant="secondary" onClick={doImport}>Import backup…</Button>
|
<SettingsRow icon={Upload} title="Import backup" desc="Restore from a previously exported file. Replaces current data.">
|
||||||
<Button variant="secondary" onClick={loadSample}>Load sample campaign</Button>
|
<Button variant="secondary" size="sm" onClick={doImport}>Import backup…</Button>
|
||||||
</div>
|
</SettingsRow>
|
||||||
<p className="mt-2 text-xs text-muted">Backups include every campaign and all of its data, as a single JSON file.</p>
|
<SettingsRow icon={Sparkles} title="Sample campaign" desc="Load a ready-made campaign to explore the app.">
|
||||||
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
|
<Button variant="secondary" size="sm" onClick={loadSample}>Load sample campaign</Button>
|
||||||
</section>
|
</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">
|
<SettingsCard label="Data sources & licenses">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources & licenses</h2>
|
<SettingsRow icon={ScrollText} title="Attribution" desc="Open-licensed game content powering the compendium.">
|
||||||
<ul className="space-y-1 text-sm text-muted">
|
<Palette className="text-faint" size={18} aria-hidden />
|
||||||
<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>
|
</SettingsRow>
|
||||||
<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>
|
<ul className="space-y-1 pt-3 text-sm text-muted">
|
||||||
<li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry & others.</li>
|
<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>
|
||||||
</ul>
|
<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>
|
||||||
</section>
|
<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">
|
<section className="rounded-xl border border-danger/40 bg-danger/5 p-4 sm:p-5">
|
||||||
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
|
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-danger">
|
||||||
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
|
<TriangleAlert size={15} aria-hidden /> Danger zone
|
||||||
<Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
|
</h2>
|
||||||
</section>
|
<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
|
<Modal
|
||||||
open={!!confirmRestore}
|
open={!!confirmRestore}
|
||||||
@@ -141,12 +201,16 @@ function CloudSync() {
|
|||||||
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-line bg-panel p-4">
|
<SettingsCard label="Cloud sync (optional)">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2>
|
<SettingsRow
|
||||||
<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>
|
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 ? (
|
{user ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 pt-4">
|
||||||
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="primary" disabled={busy} onClick={push}>⬆ Back up to cloud</Button>
|
<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="secondary" disabled={busy} onClick={() => setConfirmPull(true)}>⬇ Restore from cloud</Button>
|
||||||
@@ -154,19 +218,19 @@ function CloudSync() {
|
|||||||
</div>
|
</div>
|
||||||
</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" 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" />
|
<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="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
|
||||||
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
|
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
|
||||||
</div>
|
</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?"
|
<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></>}>
|
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>
|
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</SettingsCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import type { Campaign } from '@/lib/schemas';
|
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
|
||||||
|
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
|
||||||
import { diceRepo } from '@/lib/db/repositories';
|
import { diceRepo } from '@/lib/db/repositories';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
import { useEncounters } from '@/features/combat/hooks';
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
import { useNotes, useNpcs, useQuests, useCalendar } from './hooks';
|
import { useNotes, useNpcs, useQuests, useCalendar } from './hooks';
|
||||||
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
import { Page, RequireCampaign } from '@/components/ui/Page';
|
||||||
|
import { Meter, Badge, Avatar } from '@/components/ui/Codex';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
||||||
@@ -42,12 +45,24 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
{/* Editorial hero */}
|
||||||
title={campaign.name}
|
<section className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
|
||||||
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`}
|
<div className="px-7 py-7 sm:px-9">
|
||||||
/>
|
<div className="font-display text-sm italic text-accent">
|
||||||
|
{sys.label}
|
||||||
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>}
|
{calendar ? ` · in-world day ${calendar.currentDay}` : ''}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 font-display text-4xl font-semibold leading-[1.04] tracking-tight text-ink">
|
||||||
|
{campaign.name}
|
||||||
|
</h1>
|
||||||
|
{campaign.description && (
|
||||||
|
<p className="mt-2.5 max-w-2xl font-display text-base italic text-muted">
|
||||||
|
{campaign.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<hr className="gilt-rule" />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Quick stats */}
|
{/* Quick stats */}
|
||||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
@@ -61,13 +76,13 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
|||||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
{/* Navigation cards */}
|
{/* Navigation cards */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Jump to</h2>
|
<h2 className="smallcaps mb-3">Jump to</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{LINKS.map((l) => (
|
{LINKS.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
key={l.to}
|
key={l.to}
|
||||||
to={l.to}
|
to={l.to}
|
||||||
className="flex flex-col items-center gap-1 rounded-lg border border-line bg-panel p-4 text-center transition-colors hover:border-accent/50"
|
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
|
||||||
>
|
>
|
||||||
<span className="text-2xl" aria-hidden>{l.icon}</span>
|
<span className="text-2xl" aria-hidden>{l.icon}</span>
|
||||||
<span className="text-sm text-ink">{l.label}</span>
|
<span className="text-sm text-ink">{l.label}</span>
|
||||||
@@ -76,54 +91,180 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Party overview */}
|
{/* Party overview */}
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Party</h2>
|
<div className="mb-3 mt-7 flex items-center justify-between">
|
||||||
|
<h2 className="smallcaps">Party</h2>
|
||||||
|
<Link to="/characters" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
|
||||||
|
Manage <ChevronRight size={14} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
{pcs.length === 0 ? (
|
{pcs.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No player characters yet.</p>
|
<p className="text-sm text-muted">No player characters yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{pcs.map((c) => (
|
{pcs.map((c) => (
|
||||||
<Link
|
<PartyRow key={c.id} c={c} sys={sys} />
|
||||||
key={c.id}
|
|
||||||
to="/characters/$characterId"
|
|
||||||
params={{ characterId: c.id }}
|
|
||||||
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-2 text-sm hover:border-accent/40"
|
|
||||||
>
|
|
||||||
<span className="truncate text-ink">{c.name} <span className="text-muted">Lv {c.level}</span></span>
|
|
||||||
<span className="shrink-0 text-muted">
|
|
||||||
HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cast & threats */}
|
||||||
|
{npcs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="smallcaps mb-3 mt-7">Cast & Threats</h2>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{npcs.slice(0, 6).map((n) => (
|
||||||
|
<ThreatRow key={n.id} n={n} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Recent rolls */}
|
{/* Side column: quests + recent rolls */}
|
||||||
<aside>
|
<aside className="space-y-6">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
|
<div>
|
||||||
{rolls.length === 0 ? (
|
<h2 className="smallcaps mb-3">Quest Log</h2>
|
||||||
<p className="text-sm text-muted">No rolls yet.</p>
|
{quests.length === 0 ? (
|
||||||
) : (
|
<p className="text-sm text-muted">No quests yet.</p>
|
||||||
<ul className="space-y-1">
|
) : (
|
||||||
{rolls.map((r) => (
|
<div className="paper-grain space-y-3 rounded-xl border border-line bg-panel p-4">
|
||||||
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
{quests.slice(0, 6).map((q) => (
|
||||||
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
|
<QuestRow key={q.id} q={q} />
|
||||||
<span className="font-display font-semibold text-ink">{r.total}</span>
|
))}
|
||||||
</li>
|
</div>
|
||||||
))}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="smallcaps">Recent rolls</h2>
|
||||||
|
<Link to="/dice" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
|
||||||
|
Open tray <ChevronRight size={14} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{rolls.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No rolls yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rolls.map((r) => (
|
||||||
|
<RollRow key={r.id} r={r} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: number }) {
|
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
|
||||||
|
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
|
||||||
|
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||||
|
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
<Link
|
||||||
<div className="font-display text-3xl font-bold text-accent">{value}</div>
|
to="/characters/$characterId"
|
||||||
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
|
params={{ characterId: c.id }}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
|
||||||
|
>
|
||||||
|
<Avatar name={c.name} size={42} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="truncate font-display text-sm font-semibold text-ink">{c.name}</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-faint">{c.hp.current}/{c.hp.max}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-xs text-faint">
|
||||||
|
Lv {c.level} {[c.ancestry, c.className].filter(Boolean).join(' ')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<Meter value={c.hp.current} max={c.hp.max} height={5} {...(tone ? { tone } : {})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-9 shrink-0 flex-col items-center">
|
||||||
|
<Shield size={15} className="text-muted" aria-hidden />
|
||||||
|
<span className="font-mono text-sm font-semibold text-ink">{ac}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NPC_DISPOSITION: Record<Npc['status'], { tone: 'ember' | 'verdigris' | 'default'; label: string; Icon: typeof Skull }> = {
|
||||||
|
dead: { tone: 'ember', label: 'Hostile', Icon: Skull },
|
||||||
|
alive: { tone: 'verdigris', label: 'Friendly', Icon: Handshake },
|
||||||
|
unknown: { tone: 'default', label: 'Unknown', Icon: VenetianMask },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ThreatRow({ n }: { n: Npc }) {
|
||||||
|
const d = NPC_DISPOSITION[n.status];
|
||||||
|
const Icon = d.Icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/npcs"
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
|
||||||
|
>
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-line bg-surface-2 text-muted">
|
||||||
|
<Icon size={16} aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-display text-sm font-semibold text-ink">{n.name}</div>
|
||||||
|
<div className="truncate text-xs text-faint">
|
||||||
|
{[n.role, n.location].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge tone={d.tone}>{d.label}</Badge>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestRow({ q }: { q: Quest }) {
|
||||||
|
const done = q.status === 'completed';
|
||||||
|
return (
|
||||||
|
<div className={cn(done && 'opacity-55')}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
{done ? (
|
||||||
|
<CheckCheck size={16} className="shrink-0 text-accent-deep" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ScrollText size={16} className="shrink-0 text-muted" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className={cn('truncate font-display text-sm font-semibold text-ink', done && 'line-through')}>
|
||||||
|
{q.title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{q.objectives.length > 0 && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
{q.objectives.slice(0, 6).map((o) => (
|
||||||
|
<span
|
||||||
|
key={o.id}
|
||||||
|
className={cn('size-[7px] rounded-full', o.done ? 'bg-accent' : 'bg-line-strong')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollRow({ r }: { r: DiceRoll }) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2 text-sm">
|
||||||
|
<span className="h-6 w-[3px] shrink-0 rounded-sm bg-accent" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted">
|
||||||
|
{r.label ? `${r.label}: ` : ''}
|
||||||
|
<span className="font-mono">{r.expression}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-display font-semibold text-ink">{r.total}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||||
|
<div className="font-mono text-3xl font-bold text-accent-deep">{value}</div>
|
||||||
|
<div className="smallcaps mt-1" style={{ fontSize: 10 }}>{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Map as MapIcon, Cast, Download, ExternalLink, X } from 'lucide-react';
|
||||||
import type { BattleMap, Campaign } from '@/lib/schemas';
|
import type { BattleMap, Campaign } from '@/lib/schemas';
|
||||||
import { mapsRepo } from '@/lib/db/repositories';
|
import { mapsRepo } from '@/lib/db/repositories';
|
||||||
import { parseUvtt, toUvtt } from '@/lib/vtt/uvtt';
|
import { parseUvtt, toUvtt } from '@/lib/vtt/uvtt';
|
||||||
@@ -68,6 +69,7 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
|||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow="The cartographer's table"
|
||||||
title="Maps"
|
title="Maps"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign.name}
|
||||||
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>}
|
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>}
|
||||||
@@ -79,30 +81,44 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
|||||||
{maps.length === 0 ? (
|
{maps.length === 0 ? (
|
||||||
<EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls, doors and lights come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} />
|
<EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls, doors and lights come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 lg:grid-cols-[210px_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[230px_1fr]">
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1.5">
|
||||||
{maps.map((mp) => (
|
{maps.map((mp) => {
|
||||||
<li key={mp.id} className="group flex items-center gap-1 rounded-md border border-line">
|
const active = selected?.id === mp.id;
|
||||||
<button className={cn('flex-1 truncate px-3 py-2 text-left text-sm', selected?.id === mp.id ? 'text-accent' : 'text-ink')} onClick={() => setSelectedId(mp.id)}>
|
return (
|
||||||
{mp.name}{activeMapId === mp.id ? ' 📺' : ''}
|
<li key={mp.id} className={cn('group flex items-center gap-1 rounded-xl border bg-panel transition-colors', active ? 'border-accent bg-accent-glow' : 'border-line hover:border-line-strong')}>
|
||||||
</button>
|
<button className={cn('flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm', active ? 'text-accent-deep' : 'text-ink')} onClick={() => setSelectedId(mp.id)}>
|
||||||
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}>✕</button>
|
<MapIcon size={15} className={active ? 'text-accent-deep' : 'text-muted'} aria-hidden />
|
||||||
</li>
|
<span className="truncate font-display font-medium">{mp.name}</span>
|
||||||
))}
|
{activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}>
|
||||||
|
<X size={14} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
|
||||||
<Button size="sm" variant={activeMapId === selected.id ? 'primary' : 'secondary'} onClick={() => setActiveMap(activeMapId === selected.id ? null : selected.id)}>
|
<Button size="sm" variant={activeMapId === selected.id ? 'primary' : 'secondary'} onClick={() => setActiveMap(activeMapId === selected.id ? null : selected.id)}>
|
||||||
{activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'}
|
<Cast size={15} aria-hidden />
|
||||||
|
{activeMapId === selected.id ? 'Showing to players' : 'Show to players'}
|
||||||
|
</Button>
|
||||||
|
<Link to="/play" className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent-deep hover:underline">Open player view <ExternalLink size={13} aria-hidden /></Link>
|
||||||
|
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => void exportUvtt(selected)} title="Export as Universal VTT (.uvtt)">
|
||||||
|
<Download size={15} aria-hidden />
|
||||||
|
Export .uvtt
|
||||||
</Button>
|
</Button>
|
||||||
<Link to="/play" className="text-sm text-accent hover:underline">Open player view ↗</Link>
|
|
||||||
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => void exportUvtt(selected)} title="Export as Universal VTT (.uvtt)">Export .uvtt</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<MapEditor key={selected.id} map={selected} campaign={campaign} />
|
<MapEditor key={selected.id} map={selected} campaign={campaign} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a map.</div>
|
<div className="grid place-items-center rounded-xl border border-dashed border-line bg-panel/50 p-10 text-center text-sm text-muted">
|
||||||
|
<MapIcon size={26} className="mb-2 text-faint" aria-hidden />
|
||||||
|
Select a map.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
|
||||||
|
ChevronRight, type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
||||||
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
|
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
|
||||||
import { nextTurn, previousTurn, applyDamage, applyHealing, updateCombatant, logEvent } from '@/lib/combat/engine';
|
import { nextTurn, previousTurn, applyDamage, applyHealing, updateCombatant, logEvent } from '@/lib/combat/engine';
|
||||||
@@ -24,6 +28,11 @@ import { TokenPalette, type TokenSpec } from './TokenPalette';
|
|||||||
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
|
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
|
||||||
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls';
|
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls';
|
||||||
|
|
||||||
|
const TOOL_ICON: Record<Tool, LucideIcon> = {
|
||||||
|
move: Move, reveal: ScanEye, hide: EyeOff, measure: Ruler,
|
||||||
|
aoe: Cloud, draw: PenTool, ping: Crosshair, walls: Spline,
|
||||||
|
};
|
||||||
|
|
||||||
/** Distance from point p to segment a-b (image px). */
|
/** Distance from point p to segment a-b (image px). */
|
||||||
function distToSegment(p: Point, a: Point, b: Point): number {
|
function distToSegment(p: Point, a: Point, b: Point): number {
|
||||||
const dx = b.x - a.x, dy = b.y - a.y;
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
@@ -238,17 +247,28 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2 text-sm paper-grain">
|
||||||
{!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens ▸</Button>}
|
{!showPalette && (
|
||||||
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
<Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>
|
||||||
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => (
|
Tokens <ChevronRight size={14} aria-hidden />
|
||||||
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
</Button>
|
||||||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
|
)}
|
||||||
))}
|
<Input className="h-8 max-w-36 font-display" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||||||
|
<span className="mx-1 h-5 w-px bg-line" aria-hidden />
|
||||||
|
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => {
|
||||||
|
const ToolIcon = TOOL_ICON[t];
|
||||||
|
return (
|
||||||
|
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
||||||
|
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">
|
||||||
|
<ToolIcon size={15} aria-hidden />
|
||||||
|
{t}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contextual sub-toolbar */}
|
{/* Contextual sub-toolbar */}
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-xs text-muted">
|
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel-2 p-2 text-xs text-muted">
|
||||||
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
|
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
|
||||||
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
|
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
|
||||||
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
|
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
|
||||||
@@ -300,12 +320,15 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="mx-1 h-4 w-px bg-line" />
|
<span className="mx-1 h-4 w-px bg-line" />
|
||||||
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
|
<Button size="sm" variant="secondary" onClick={addToken}>
|
||||||
|
<CirclePlus size={14} aria-hidden />
|
||||||
|
+ Token
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeEncounter && (
|
{activeEncounter && (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/5 p-2 text-sm">
|
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
|
||||||
<span className="text-xs uppercase tracking-wide text-muted">Combat · Round {activeEncounter.round}</span>
|
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
|
||||||
<span className="font-display font-semibold text-ink">▶ {activeCombatant?.name ?? '—'}</span>
|
<span className="font-display font-semibold text-ink">▶ {activeCombatant?.name ?? '—'}</span>
|
||||||
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}>‹ Prev</Button>
|
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}>‹ Prev</Button>
|
||||||
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn ›</Button>
|
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn ›</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user