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
+64
View File
@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
async function makeCampaign(page: import('@playwright/test').Page, name: string) {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill(name);
await page.getByRole('button', { name: 'Create' }).click();
}
test('combat HP damage button reduces hit points', async ({ page }) => {
await makeCampaign(page, 'Combat Test');
await page.getByRole('link', { name: 'Combat' }).click();
await page.getByRole('button', { name: '+ New encounter' }).first().click();
await page.locator('input[data-autofocus]').fill('Fight');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Name').fill('Goblin');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await expect(page.getByText('10/10')).toBeVisible();
// Enter 4 damage and apply
await page.getByLabel('Goblin HP amount').fill('4');
await page.getByRole('button', { name: 'Dmg' }).click();
await expect(page.getByText('6/10')).toBeVisible();
// Heal 2 back
await page.getByLabel('Goblin HP amount').fill('2');
await page.getByRole('button', { name: 'Heal' }).click();
await expect(page.getByText('8/10')).toBeVisible();
});
test('character can be deleted', async ({ page }) => {
await makeCampaign(page, 'Delete Test');
await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.locator('input[data-autofocus]').fill('Doomed');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('heading', { name: 'Doomed' })).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete', exact: true }).last().click();
await expect(page.getByRole('heading', { name: 'Doomed' })).toHaveCount(0);
});
test('compendium CR filter narrows the bestiary', async ({ page }) => {
await makeCampaign(page, 'Filter Test');
await page.getByRole('link', { name: 'Compendium' }).click();
await expect(page.getByText(/results/)).toBeVisible();
const countText = async () => (await page.getByText(/results/).textContent()) ?? '';
const before = parseInt((await countText()).replace(/\D/g, ''), 10);
await page.getByLabel('Minimum CR').selectOption({ label: 'CR ≥ 10' });
// wait for the count to update
await expect.poll(async () => parseInt((await countText()).replace(/\D/g, ''), 10)).toBeLessThan(before);
});
+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');
+5 -5
View File
@@ -268,12 +268,12 @@ function CombatantRow({
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
</div>
<div className="mt-1 flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP change`} />
<Button size="sm" variant="danger" onClick={() => { onDamage(delta); setDelta(0); }}>
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
Dmg
</Button>
<Button size="sm" variant="secondary" onClick={() => { onHeal(delta); setDelta(0); }}>
+
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
Heal
</Button>
</div>
</div>
+146 -6
View File
@@ -11,7 +11,7 @@ import { encountersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Input, Select } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
import { MonsterDetail } from './MonsterDetail';
@@ -23,11 +23,55 @@ const TABS: { kind: CompendiumKind; label: string }[] = [
{ kind: 'items', label: 'Magic Items' },
];
interface Filters {
type?: string;
school?: string;
level?: number;
rarity?: string;
crMin?: number;
crMax?: number;
}
function distinct<T>(items: T[], get: (x: T) => string | undefined): string[] {
return [...new Set(items.map(get).filter((v): v is string => !!v && v.trim() !== ''))].sort((a, b) =>
a.localeCompare(b),
);
}
function crNumLabel(n: number): string {
if (n === 0.125) return '1/8';
if (n === 0.25) return '1/4';
if (n === 0.5) return '1/2';
return String(n);
}
function applyFilters(kind: CompendiumKind, data: AnyEntry[], f: Filters): AnyEntry[] {
if (kind === 'monsters') {
return (data as Monster[]).filter((m) => {
const cr = m.cr ?? 0;
return (
(!f.type || m.type === f.type) &&
(f.crMin === undefined || cr >= f.crMin) &&
(f.crMax === undefined || cr <= f.crMax)
);
});
}
if (kind === 'spells') {
return (data as Spell[]).filter(
(s) => (f.level === undefined || s.level_int === f.level) && (!f.school || s.school === f.school),
);
}
return (data as MagicItem[]).filter(
(i) => (!f.rarity || i.rarity === f.rarity) && (!f.type || i.type === f.type),
);
}
export function CompendiumPage() {
const [kind, setKind] = useState<CompendiumKind>('monsters');
const [data, setData] = useState<AnyEntry[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<Filters>({});
const [selected, setSelected] = useState<AnyEntry | null>(null);
useEffect(() => {
@@ -45,15 +89,23 @@ export function CompendiumPage() {
};
}, [kind]);
const filtered = useMemo(() => applyFilters(kind, data, filters), [kind, data, filters]);
const fuse = useMemo(
() => new Fuse(data, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }),
[data],
() => new Fuse(filtered, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }),
[filtered],
);
const results = useMemo(() => {
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : data;
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : filtered;
return list.slice(0, 300);
}, [query, fuse, data]);
}, [query, fuse, filtered]);
const switchTab = (k: CompendiumKind) => {
setKind(k);
setQuery('');
setFilters({});
};
return (
<Page>
@@ -63,7 +115,7 @@ export function CompendiumPage() {
{TABS.map((t) => (
<button
key={t.kind}
onClick={() => { setKind(t.kind); setQuery(''); }}
onClick={() => switchTab(t.kind)}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors',
kind === t.kind ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink',
@@ -82,6 +134,9 @@ export function CompendiumPage() {
placeholder={`Search ${kind}`}
aria-label="Search compendium"
/>
<CompendiumFilters kind={kind} data={data} filters={filters} onChange={setFilters} />
<p className="mt-1 text-xs text-muted">
{loading ? 'Loading…' : `${results.length}${results.length === 300 ? '+' : ''} results`}
</p>
@@ -115,6 +170,91 @@ export function CompendiumPage() {
);
}
function CompendiumFilters({
kind,
data,
filters,
onChange,
}: {
kind: CompendiumKind;
data: AnyEntry[];
filters: Filters;
onChange: (f: Filters) => void;
}) {
const options = useMemo(() => {
if (kind === 'monsters') {
const m = data as Monster[];
const crs = [...new Set(m.map((x) => x.cr).filter((c): c is number => c !== undefined))].sort((a, b) => a - b);
return { types: distinct(m, (x) => x.type), crs };
}
if (kind === 'spells') {
const s = data as Spell[];
const levels = [...new Set(s.map((x) => x.level_int).filter((l): l is number => l !== undefined))].sort((a, b) => a - b);
return { schools: distinct(s, (x) => x.school), levels };
}
const i = data as MagicItem[];
return { rarities: distinct(i, (x) => x.rarity), itemTypes: distinct(i, (x) => x.type) };
}, [kind, data]);
const setKey = <K extends keyof Filters>(key: K, value: Filters[K] | undefined) => {
const next: Filters = { ...filters };
if (value === undefined) delete next[key];
else next[key] = value;
onChange(next);
};
const numOrUndef = (v: string) => (v === '' ? undefined : Number(v));
const strOrUndef = (v: string) => v || undefined;
const active = Object.values(filters).some((v) => v !== undefined);
return (
<div className="mt-2 flex flex-wrap items-center gap-2">
{kind === 'monsters' && (
<>
<Select className="w-auto py-1 text-xs" aria-label="Filter by type" value={filters.type ?? ''} onChange={(e) => setKey('type', strOrUndef(e.target.value))}>
<option value="">Any type</option>
{options.types!.map((t) => <option key={t} value={t}>{t}</option>)}
</Select>
<Select className="w-auto py-1 text-xs" aria-label="Minimum CR" value={filters.crMin ?? ''} onChange={(e) => setKey('crMin', numOrUndef(e.target.value))}>
<option value="">CR any</option>
{options.crs!.map((c) => <option key={c} value={c}>CR {crNumLabel(c)}</option>)}
</Select>
<Select className="w-auto py-1 text-xs" aria-label="Maximum CR" value={filters.crMax ?? ''} onChange={(e) => setKey('crMax', numOrUndef(e.target.value))}>
<option value="">CR any</option>
{options.crs!.map((c) => <option key={c} value={c}>CR {crNumLabel(c)}</option>)}
</Select>
</>
)}
{kind === 'spells' && (
<>
<Select className="w-auto py-1 text-xs" aria-label="Filter by level" value={filters.level ?? ''} onChange={(e) => setKey('level', numOrUndef(e.target.value))}>
<option value="">Any level</option>
{options.levels!.map((l) => <option key={l} value={l}>{l === 0 ? 'Cantrip' : `Level ${l}`}</option>)}
</Select>
<Select className="w-auto py-1 text-xs" aria-label="Filter by school" value={filters.school ?? ''} onChange={(e) => setKey('school', strOrUndef(e.target.value))}>
<option value="">Any school</option>
{options.schools!.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
</>
)}
{kind === 'items' && (
<>
<Select className="w-auto py-1 text-xs" aria-label="Filter by rarity" value={filters.rarity ?? ''} onChange={(e) => setKey('rarity', strOrUndef(e.target.value))}>
<option value="">Any rarity</option>
{options.rarities!.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
<Select className="w-auto py-1 text-xs" aria-label="Filter by item type" value={filters.type ?? ''} onChange={(e) => setKey('type', strOrUndef(e.target.value))}>
<option value="">Any type</option>
{options.itemTypes!.map((t) => <option key={t} value={t}>{t}</option>)}
</Select>
</>
)}
{active && (
<Button size="sm" variant="ghost" onClick={() => onChange({})}>Clear</Button>
)}
</div>
);
}
function EntryMeta({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
if (kind === 'monsters') return <span className="text-xs text-muted">CR {crLabel(entry as Monster)}</span>;
if (kind === 'spells') {
+48 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
@@ -7,6 +7,7 @@ import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
@@ -14,6 +15,7 @@ export function DicePage() {
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
const [expr, setExpr] = useState('1d20');
const [last, setLast] = useState<RollResult | null>(null);
const [rollCount, setRollCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []);
@@ -22,6 +24,7 @@ export function DicePage() {
try {
const result = rollDice(expression, createRng());
setLast(result);
setRollCount((n) => n + 1);
setError(null);
await diceRepo.add({
...(campaignId ? { campaignId } : {}),
@@ -83,9 +86,12 @@ export function DicePage() {
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
{last && (
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center" aria-live="polite">
<div className="font-display text-6xl font-bold text-accent">{last.total}</div>
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center">
<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>
@@ -115,3 +121,42 @@ export function DicePage() {
</Page>
);
}
/**
* Shows the rolling die "tumbling" through random numbers before settling on the
* real total. Mounted with a per-roll key so each roll replays the animation.
*/
function AnimatedTotal({ result }: { result: RollResult }) {
const [display, setDisplay] = useState(result.total);
const [rolling, setRolling] = useState(true);
useEffect(() => {
setRolling(true);
let frames = 0;
const maxFrames = 9;
const span = Math.max(6, Math.abs(result.total) + 4);
const id = setInterval(() => {
frames++;
if (frames >= maxFrames) {
clearInterval(id);
setDisplay(result.total);
setRolling(false);
} else {
setDisplay(Math.floor(Math.random() * span) + 1);
}
}, 45);
return () => clearInterval(id);
}, [result]);
return (
<div
aria-hidden
className={cn(
'font-display text-6xl font-bold text-accent tabular-nums',
rolling ? 'animate-dice-rolling opacity-80' : 'animate-dice-settle',
)}
>
{display}
</div>
);
}
+5
View File
@@ -102,6 +102,11 @@ export const charactersRepo = {
await db.characters.update(id, { ...patch, updatedAt: now() });
},
/** Insert a fully-formed character (e.g. from an import). Validates on write. */
async insert(character: Character): Promise<void> {
await db.characters.add(characterSchema.parse(character));
},
async remove(id: string): Promise<void> {
await db.characters.delete(id);
},
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { parseCharacterImport, CharacterImportError } from './character';
import { characterSchema, characterDefaults, type Character } from '@/lib/schemas';
function sampleCharacter(): Character {
return characterSchema.parse({
id: 'orig', campaignId: 'orig-camp', system: '5e', kind: 'pc',
name: 'Imported Hero', ancestry: 'Elf', className: 'Ranger', level: 4,
abilities: { str: 12, dex: 16, con: 14, int: 10, wis: 13, cha: 8 },
hp: { current: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 2,
skillRanks: { stealth: 'expert' }, saveRanks: {}, perceptionRank: 'trained',
...characterDefaults(),
notes: 'hi', createdAt: 'x', updatedAt: 'x',
});
}
describe('parseCharacterImport', () => {
it('round-trips a wrapped export, reassigning id + campaign', () => {
const c = sampleCharacter();
const file = JSON.stringify({ format: 'ttrpg-manager:character', version: 1, character: c });
const imported = parseCharacterImport(file, 'new-camp');
expect(imported.name).toBe('Imported Hero');
expect(imported.campaignId).toBe('new-camp');
expect(imported.id).not.toBe('orig');
expect(imported.abilities.dex).toBe(16);
expect(imported.skillRanks['stealth']).toBe('expert');
});
it('accepts a bare character object too', () => {
const c = sampleCharacter();
const imported = parseCharacterImport(JSON.stringify(c), 'camp2');
expect(imported.name).toBe('Imported Hero');
expect(imported.campaignId).toBe('camp2');
});
it('rejects invalid JSON', () => {
expect(() => parseCharacterImport('{not json', 'c')).toThrow(CharacterImportError);
});
it('rejects a file that is not a character', () => {
expect(() => parseCharacterImport(JSON.stringify({ foo: 1 }), 'c')).toThrow(CharacterImportError);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { characterSchema, type Character } from '@/lib/schemas';
import { newId } from '@/lib/ids';
import { downloadJson, safeFilename } from './file';
const FORMAT = 'ttrpg-manager:character';
const VERSION = 1;
interface CharacterFile {
format: typeof FORMAT;
version: number;
character: Character;
}
/** Download a character as a portable JSON file. */
export function exportCharacter(c: Character): void {
const payload: CharacterFile = { format: FORMAT, version: VERSION, character: c };
downloadJson(`${safeFilename(c.name)}.character.json`, payload);
}
export class CharacterImportError extends Error {}
/**
* Parse imported JSON into a valid Character bound to the given campaign. Accepts
* both our wrapped export format and a bare character object. Always assigns a
* fresh id + timestamps so importing never collides with or overwrites existing
* data.
*/
export function parseCharacterImport(text: string, campaignId: string): Character {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new CharacterImportError('That file is not valid JSON.');
}
const candidate =
raw && typeof raw === 'object' && 'character' in raw
? (raw as { character: unknown }).character
: raw;
if (!candidate || typeof candidate !== 'object') {
throw new CharacterImportError('No character data found in that file.');
}
const now = new Date().toISOString();
const result = characterSchema.safeParse({
...(candidate as Record<string, unknown>),
id: newId(),
campaignId,
createdAt: now,
updatedAt: now,
});
if (!result.success) {
throw new CharacterImportError(
`That file isn't a valid character: ${result.error.issues[0]?.message ?? 'unknown error'}`,
);
}
return result.data;
}
+41
View File
@@ -0,0 +1,41 @@
/** Trigger a browser download of a JSON-serializable value. */
export function downloadJson(filename: string, data: unknown): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// revoke on the next tick so the download has started
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Open the OS file picker and resolve with the chosen file's text (or null if cancelled). */
export function pickTextFile(accept = 'application/json,.json'): Promise<string | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept;
input.onchange = () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
reader.onerror = () => resolve(null);
reader.readAsText(file);
};
// If the user cancels, onchange never fires; that's fine — the promise just
// never resolves and is garbage-collected with the input.
input.click();
});
}
/** Make a string safe to use as a filename. */
export function safeFilename(name: string): string {
return name.replace(/[^a-z0-9-_]+/gi, '_').replace(/^_+|_+$/g, '') || 'export';
}
+40
View File
@@ -84,3 +84,43 @@ body {
scrollbar-width: thin;
scrollbar-color: var(--app-line) transparent;
}
/* Dice roll animations */
@keyframes dice-tumble {
0% {
transform: scale(0.7) rotate(-12deg);
opacity: 0.5;
}
50% {
transform: scale(1.12) rotate(8deg);
}
75% {
transform: scale(0.96) rotate(-3deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
.animate-dice-settle {
animation: dice-tumble 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes dice-shake {
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
25% {
transform: translate(-2px, 1px) rotate(-3deg);
}
50% {
transform: translate(2px, -1px) rotate(3deg);
}
75% {
transform: translate(-1px, 2px) rotate(-2deg);
}
}
.animate-dice-rolling {
animation: dice-shake 0.12s linear infinite;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/rules/abilities.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}