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