Fixes + UX: combat HP, char delete, dice animation, compendium filters, import/export

- Combat: relabel HP controls Dmg/Heal, disabled at 0 (were silent no-ops)
- Characters: per-card Delete (confirm) + Export; header Import from JSON
- Dice: tumbling roll animation that settles on the result (a11y-safe)
- Compendium: per-category filters (monster type/CR, spell level/school, item rarity/type) + Clear
- Character import/export to portable JSON (reassigns id, validates on import)
- New: 4 io unit tests, e2e fixes spec (HP damage, delete, CR filter)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:40:43 +02:00
parent 9ecd817bc6
commit 866f1e1bf1
11 changed files with 554 additions and 41 deletions
+102 -26
View File
@@ -3,6 +3,8 @@ import { Link } from '@tanstack/react-router';
import { charactersRepo } from '@/lib/db/repositories';
import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { pickTextFile } from '@/lib/io/file';
import { useCharacters } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
@@ -16,25 +18,49 @@ export function CharactersPage() {
function CharactersList({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const [creating, setCreating] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const pcs = characters.filter((c) => c.kind === 'pc');
const npcs = characters.filter((c) => c.kind === 'npc');
const importCharacter = async () => {
setImportError(null);
const text = await pickTextFile();
if (text === null) return;
try {
const character = parseCharacterImport(text, campaign.id);
await charactersRepo.insert(character);
} catch (e) {
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
}
};
return (
<Page>
<PageHeader
title="Characters"
subtitle={campaign.name}
actions={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New character
</Button>
<>
<Button variant="secondary" onClick={importCharacter}>
Import
</Button>
<Button variant="primary" onClick={() => setCreating(true)}>
+ New character
</Button>
</>
}
/>
{importError && (
<p className="mb-4 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
{importError}
</p>
)}
{characters.length === 0 ? (
<EmptyState
title="No characters yet"
hint="Add player characters and NPCs to this campaign."
hint="Add player characters and NPCs, or import a character file."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New character
@@ -60,34 +86,84 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((c) => (
<Link
key={c.id}
to="/characters/$characterId"
params={{ characterId: c.id }}
className="rounded-lg border border-line bg-panel p-4 transition-colors hover:border-accent/50"
>
<div className="flex items-baseline justify-between">
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
<span className="text-sm text-muted">Lv {c.level}</span>
</div>
<p className="mt-0.5 text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p>
<div className="mt-3 flex gap-4 text-sm">
<span className="text-muted">
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
</span>
<span className="text-muted">
AC <span className="font-medium text-ink">{getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}</span>
</span>
</div>
</Link>
<CharacterCard key={c.id} c={c} />
))}
</div>
</section>
);
}
function CharacterCard({ c }: { c: Character }) {
const [confirming, setConfirming] = useState(false);
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
return (
<div className="flex flex-col rounded-lg border border-line bg-panel transition-colors hover:border-accent/50">
<Link
to="/characters/$characterId"
params={{ characterId: c.id }}
className="block p-4"
>
<div className="flex items-baseline justify-between">
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
<span className="text-sm text-muted">Lv {c.level}</span>
</div>
<p className="mt-0.5 text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p>
<div className="mt-3 flex gap-4 text-sm">
<span className="text-muted">
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
</span>
<span className="text-muted">
AC <span className="font-medium text-ink">{ac}</span>
</span>
</div>
</Link>
<div className="flex items-center gap-1 border-t border-line px-2 py-1.5">
<Button size="sm" variant="ghost" onClick={() => exportCharacter(c)} title="Export to JSON file">
Export
</Button>
<Button
size="sm"
variant="ghost"
className="ml-auto text-danger hover:bg-danger/10"
onClick={() => setConfirming(true)}
>
Delete
</Button>
</div>
<Modal
open={confirming}
onClose={() => setConfirming(false)}
title="Delete character?"
footer={
<>
<Button variant="ghost" onClick={() => setConfirming(false)}>
Cancel
</Button>
<Button
variant="danger"
onClick={async () => {
await charactersRepo.remove(c.id);
setConfirming(false);
}}
>
Delete
</Button>
</>
}
>
<p className="text-sm text-muted">
Permanently delete <strong className="text-ink">{c.name}</strong>? This cannot be undone.
{' '}Consider exporting first.
</p>
</Modal>
</div>
);
}
function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc');