Phase 9: homebrew content + packs

- Homebrew entity (monster/spell/item/feat/condition) — Dexie v6, repo, hook,
  cascade-deleted with campaign
- Homebrew editor page: per-kind fields + description, create/edit/delete
- Compendium merges campaign homebrew of the matching kind (HB badge, generic
  HomebrewDetail) with the same cross-links (add monster->combat, spell/item->character)
- Content packs: export/import homebrew as JSON (validated, ids reassigned)
- System extensibility documented via the existing RulesSystem registry seam
- Fix: card editors (homebrew/npc/quest/note) debounce-save the FULL snapshot, not
  partial patches — editing two fields fast no longer drops an edit

New homebrew e2e + cascade test coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 07:54:53 +02:00
parent 744e91f703
commit 7100ea8dd4
16 changed files with 383 additions and 28 deletions
+13 -7
View File
@@ -99,7 +99,16 @@ function NoteEditor({
const [body, setBody] = useState(note.body);
const [tags, setTags] = useState(note.tags.join(', '));
const save = useDebouncedCallback((patch: Partial<Note>) => void notesRepo.update(note.id, patch), 350);
// Save the full snapshot so editing title + body quickly doesn't drop an edit.
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
void notesRepo.update(note.id, {
title: next.title,
body: next.body,
tags: next.tags.split(',').map((t) => t.trim()).filter(Boolean),
});
}, 350);
const persist = (over: Partial<{ title: string; body: string; tags: string }>) =>
save({ title, body, tags, ...over });
const backlinks = allNotes.filter(
(n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()),
@@ -113,7 +122,7 @@ function NoteEditor({
className="font-display text-lg font-semibold"
value={title}
aria-label="Note title"
onChange={(e) => { setTitle(e.target.value); save({ title: e.target.value }); }}
onChange={(e) => { setTitle(e.target.value); persist({ title: e.target.value }); }}
/>
<Button
variant="ghost"
@@ -128,17 +137,14 @@ function NoteEditor({
Tags (comma-separated)
<Input
value={tags}
onChange={(e) => {
setTags(e.target.value);
save({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) });
}}
onChange={(e) => { setTags(e.target.value); persist({ tags: e.target.value }); }}
placeholder="lore, barovia, npc"
/>
</label>
<textarea
value={body}
onChange={(e) => { setBody(e.target.value); save({ body: e.target.value }); }}
onChange={(e) => { setBody(e.target.value); persist({ body: e.target.value }); }}
placeholder="Write here… link other notes with [[Note Title]]."
className="min-h-48 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>