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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user