Phase 5: campaign management & worldbuilding
- New entities (note/npc/quest/calendar): schemas, Dexie v4 tables, repos, hooks; campaign cascade-delete now covers them all - Dashboard hub: campaign stats, navigation cards, party overview, recent rolls; opening a campaign lands here - Notes/Wiki: tags, [[wiki-links]] with click-to-open/create, backlinks panel, full-text search, autosave - NPC manager (role/location/faction/status) and Quest tracker (status + objectives checklist + reward) - In-world Calendar: integer day counter + events (no real Date -> no timezone bug) - Fix: calendarRepo.get is read-only (creating-on-read crashed inside liveQuery) 8 new unit tests (wikilinks + extended cascade), worldbuilding e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
});
|
||||
|
||||
test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', async ({ page }) => {
|
||||
// Create campaign, then open its dashboard
|
||||
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||
await page.locator('input[data-autofocus]').fill('Barovia');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Barovia' })).toBeVisible();
|
||||
|
||||
// Notes: create two and link them
|
||||
await page.getByRole('link', { name: 'Notes & Wiki' }).click();
|
||||
await page.getByRole('button', { name: '+ New note' }).first().click();
|
||||
await page.getByLabel('Note title').fill('Strahd');
|
||||
await page.getByPlaceholder(/Write here/).fill('Lord of [[Castle Ravenloft]].');
|
||||
// The link is "missing" until created; click it to create the target note
|
||||
await page.getByRole('button', { name: 'Castle Ravenloft' }).click();
|
||||
await expect(page.getByLabel('Note title')).toHaveValue('Castle Ravenloft');
|
||||
// Backlink from Strahd should appear
|
||||
await expect(page.getByText('Linked from')).toBeVisible();
|
||||
|
||||
// NPC
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await page.getByRole('link', { name: 'NPCs' }).click();
|
||||
await page.getByRole('button', { name: '+ New NPC' }).first().click();
|
||||
await expect(page.getByLabel('NPC name')).toBeVisible();
|
||||
|
||||
// Quest with an objective
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await page.getByRole('link', { name: 'Quests' }).click();
|
||||
await page.getByRole('button', { name: '+ New quest' }).first().click();
|
||||
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(page.locator('input[value="Find the Tome"]')).toBeVisible();
|
||||
|
||||
// Calendar advances days
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await page.getByRole('link', { name: 'Calendar' }).click();
|
||||
await page.getByRole('button', { name: '+1 week' }).click();
|
||||
await expect(page.getByText('7', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Campaigns', exact: true },
|
||||
{ to: '/dashboard', label: 'Dashboard', exact: false },
|
||||
{ to: '/characters', label: 'Characters', exact: false },
|
||||
{ to: '/combat', label: 'Combat', exact: false },
|
||||
{ to: '/dice', label: 'Dice', exact: false },
|
||||
|
||||
@@ -59,7 +59,7 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
|
||||
|
||||
const open = () => {
|
||||
setActive(campaign.id);
|
||||
void navigate({ to: '/characters' });
|
||||
void navigate({ to: '/dashboard' });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import type { Calendar, Campaign } from '@/lib/schemas';
|
||||
import { calendarRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { useCalendar } from './hooks';
|
||||
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
export function CalendarPage() {
|
||||
return <RequireCampaign>{(c) => <CalendarView campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function CalendarView({ campaign }: { campaign: Campaign }) {
|
||||
const stored = useCalendar(campaign.id);
|
||||
const [eventTitle, setEventTitle] = useState('');
|
||||
|
||||
// The row is created on first save; until then we work with an in-memory default.
|
||||
const calendar: Calendar = stored ?? { campaignId: campaign.id, currentDay: 0, events: [] };
|
||||
|
||||
const save = (next: Calendar) => void calendarRepo.save(next);
|
||||
const advance = (delta: number) => save({ ...calendar, currentDay: calendar.currentDay + delta });
|
||||
const addEvent = () => {
|
||||
if (eventTitle.trim() === '') return;
|
||||
save({ ...calendar, events: [...calendar.events, { id: newId(), day: calendar.currentDay, title: eventTitle.trim() }] });
|
||||
setEventTitle('');
|
||||
};
|
||||
|
||||
const upcoming = [...calendar.events].sort((a, b) => a.day - b.day);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title="Calendar" subtitle={`${campaign.name} · in-world timeline`} />
|
||||
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-line bg-panel p-4">
|
||||
<Button variant="secondary" onClick={() => advance(-1)} aria-label="Previous day">−1 day</Button>
|
||||
<div className="text-center">
|
||||
<div className="text-xs uppercase tracking-wide text-muted">Day</div>
|
||||
<div className="font-display text-3xl font-bold text-accent">{calendar.currentDay}</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => advance(1)} aria-label="Next day">+1 day</Button>
|
||||
<Button variant="ghost" onClick={() => advance(7)}>+1 week</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<Input value={eventTitle} onChange={(e) => setEventTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addEvent()} placeholder={`Add event on day ${calendar.currentDay}…`} aria-label="Event title" />
|
||||
<Button variant="primary" onClick={addEvent}>Add event</Button>
|
||||
</div>
|
||||
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-muted">No events yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{upcoming.map((ev) => (
|
||||
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
|
||||
<span className="flex-1 text-ink">{ev.title}</span>
|
||||
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}>✕</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { diceRepo } from '@/lib/db/repositories';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { useNotes, useNpcs, useQuests, useCalendar } from './hooks';
|
||||
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
|
||||
export function DashboardPage() {
|
||||
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/characters', label: 'Characters', icon: '🧝' },
|
||||
{ to: '/combat', label: 'Combat', icon: '⚔' },
|
||||
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
|
||||
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
|
||||
{ to: '/quests', label: 'Quests', icon: '🗺' },
|
||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
||||
] as const;
|
||||
|
||||
function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const notes = useNotes(campaign.id);
|
||||
const npcs = useNpcs(campaign.id);
|
||||
const quests = useQuests(campaign.id);
|
||||
const calendar = useCalendar(campaign.id);
|
||||
const rolls = useLiveQuery(() => diceRepo.recent(campaign.id, 8), [campaign.id], []);
|
||||
|
||||
const sys = getSystem(campaign.system);
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const activeQuests = quests.filter((q) => q.status === 'active').length;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={campaign.name}
|
||||
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`}
|
||||
/>
|
||||
|
||||
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>}
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<Stat label="Characters" value={characters.length} />
|
||||
<Stat label="Encounters" value={encounters.length} />
|
||||
<Stat label="Active quests" value={activeQuests} />
|
||||
<Stat label="NPCs" value={npcs.length} />
|
||||
<Stat label="Notes" value={notes.length} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||
{/* Navigation cards */}
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Jump to</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className="flex flex-col items-center gap-1 rounded-lg border border-line bg-panel p-4 text-center transition-colors hover:border-accent/50"
|
||||
>
|
||||
<span className="text-2xl" aria-hidden>{l.icon}</span>
|
||||
<span className="text-sm text-ink">{l.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Party overview */}
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Party</h2>
|
||||
{pcs.length === 0 ? (
|
||||
<p className="text-sm text-muted">No player characters yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{pcs.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to="/characters/$characterId"
|
||||
params={{ characterId: c.id }}
|
||||
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-2 text-sm hover:border-accent/40"
|
||||
>
|
||||
<span className="truncate text-ink">{c.name} <span className="text-muted">Lv {c.level}</span></span>
|
||||
<span className="shrink-0 text-muted">
|
||||
HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent rolls */}
|
||||
<aside>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
|
||||
{rolls.length === 0 ? (
|
||||
<p className="text-sm text-muted">No rolls yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{rolls.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
|
||||
<span className="font-display font-semibold text-ink">{r.total}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
||||
<div className="font-display text-3xl font-bold text-accent">{value}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Campaign, Note } from '@/lib/schemas';
|
||||
import { notesRepo } from '@/lib/db/repositories';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { extractWikiLinks, splitWikiLinks } from '@/lib/wikilinks';
|
||||
import { useNotes } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function NotesPage() {
|
||||
return <RequireCampaign>{(c) => <Notes campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function Notes({ campaign }: { campaign: Campaign }) {
|
||||
const notes = useNotes(campaign.id);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const selected = notes.find((n) => n.id === selectedId) ?? null;
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? notes.filter((n) => n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q) || n.tags.some((t) => t.toLowerCase().includes(q)))
|
||||
: notes;
|
||||
return [...list].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}, [notes, query]);
|
||||
|
||||
const createNote = async (title = 'Untitled') => {
|
||||
const note = await notesRepo.create(campaign.id, title);
|
||||
setSelectedId(note.id);
|
||||
};
|
||||
|
||||
const openByTitle = async (title: string) => {
|
||||
const existing = notes.find((n) => n.title.toLowerCase() === title.toLowerCase());
|
||||
if (existing) setSelectedId(existing.id);
|
||||
else await createNote(title);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Notes & Wiki"
|
||||
subtitle={`${campaign.name} · link entries with [[double brackets]]`}
|
||||
actions={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
|
||||
/>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No notes yet"
|
||||
hint="Capture lore, session recaps, and locations. Use [[wiki links]] to connect them."
|
||||
action={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||
<div>
|
||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search notes…" aria-label="Search notes" />
|
||||
<ul className="mt-2 max-h-[65vh] space-y-1 overflow-auto">
|
||||
{filtered.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
onClick={() => setSelectedId(n.id)}
|
||||
className={cn(
|
||||
'w-full truncate rounded-md border px-3 py-2 text-left text-sm',
|
||||
selected?.id === n.id ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
||||
)}
|
||||
>
|
||||
{n.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<NoteEditor key={selected.id} note={selected} allNotes={notes} onOpenTitle={openByTitle} onSelect={setSelectedId} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a note to edit.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteEditor({
|
||||
note,
|
||||
allNotes,
|
||||
onOpenTitle,
|
||||
onSelect,
|
||||
}: {
|
||||
note: Note;
|
||||
allNotes: Note[];
|
||||
onOpenTitle: (title: string) => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(note.title);
|
||||
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);
|
||||
|
||||
const backlinks = allNotes.filter(
|
||||
(n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()),
|
||||
);
|
||||
const titlesLower = new Set(allNotes.map((n) => n.title.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Input
|
||||
className="font-display text-lg font-semibold"
|
||||
value={title}
|
||||
aria-label="Note title"
|
||||
onChange={(e) => { setTitle(e.target.value); save({ title: e.target.value }); }}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-danger"
|
||||
onClick={async () => { await notesRepo.remove(note.id); onSelect(null); }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block text-xs text-muted">
|
||||
Tags (comma-separated)
|
||||
<Input
|
||||
value={tags}
|
||||
onChange={(e) => {
|
||||
setTags(e.target.value);
|
||||
save({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) });
|
||||
}}
|
||||
placeholder="lore, barovia, npc"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => { setBody(e.target.value); save({ 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"
|
||||
/>
|
||||
|
||||
{/* Rendered preview with clickable links */}
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Preview</h3>
|
||||
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
|
||||
{splitWikiLinks(body).map((part, i) =>
|
||||
part.link ? (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onOpenTitle(part.link!)}
|
||||
className={cn('underline', titlesLower.has(part.link.toLowerCase()) ? 'text-accent' : 'text-warning')}
|
||||
title={titlesLower.has(part.link.toLowerCase()) ? 'Open note' : 'Create this note'}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
) : (
|
||||
<span key={i}>{part.text}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{backlinks.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Linked from</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{backlinks.map((n) => (
|
||||
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
|
||||
{n.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import type { Campaign, Npc } from '@/lib/schemas';
|
||||
import { npcsRepo } from '@/lib/db/repositories';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { useNpcs } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
|
||||
const STATUS: Npc['status'][] = ['alive', 'dead', 'unknown'];
|
||||
|
||||
export function NpcsPage() {
|
||||
return <RequireCampaign>{(c) => <Npcs campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function Npcs({ campaign }: { campaign: Campaign }) {
|
||||
const npcs = useNpcs(campaign.id);
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = npcs
|
||||
.filter((n) => n.name.toLowerCase().includes(query.toLowerCase()) || n.faction.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="NPCs"
|
||||
subtitle={campaign.name}
|
||||
actions={<Button variant="primary" onClick={() => npcsRepo.create(campaign.id, 'New NPC')}>+ New NPC</Button>}
|
||||
/>
|
||||
{npcs.length === 0 ? (
|
||||
<EmptyState title="No NPCs yet" hint="Track the people your party meets — allies, villains, shopkeepers." action={<Button variant="primary" onClick={() => npcsRepo.create(campaign.id, 'New NPC')}>+ New NPC</Button>} />
|
||||
) : (
|
||||
<>
|
||||
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcCard({ npc }: { npc: Npc }) {
|
||||
const [n, setN] = useState(npc);
|
||||
const save = useDebouncedCallback((patch: Partial<Npc>) => void npcsRepo.update(npc.id, patch), 350);
|
||||
const update = (patch: Partial<Npc>) => { setN((p) => ({ ...p, ...patch })); save(patch); };
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-display font-semibold" value={n.name} onChange={(e) => update({ name: e.target.value })} aria-label="NPC name" />
|
||||
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
|
||||
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}>✕</Button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
|
||||
<Input className="text-xs" value={n.location} onChange={(e) => update({ location: e.target.value })} placeholder="Location" aria-label="Location" />
|
||||
<Input className="text-xs" value={n.faction} onChange={(e) => update({ faction: e.target.value })} placeholder="Faction" aria-label="Faction" />
|
||||
</div>
|
||||
<textarea
|
||||
value={n.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder="Appearance, motives, voice, secrets…"
|
||||
className="mt-2 min-h-20 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
||||
import { questsRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { useQuests } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
|
||||
const STATUS_COLOR: Record<Quest['status'], string> = {
|
||||
active: 'text-info', 'on-hold': 'text-muted', completed: 'text-success', failed: 'text-danger',
|
||||
};
|
||||
|
||||
export function QuestsPage() {
|
||||
return <RequireCampaign>{(c) => <Quests campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function Quests({ campaign }: { campaign: Campaign }) {
|
||||
const quests = useQuests(campaign.id);
|
||||
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
|
||||
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Quests"
|
||||
subtitle={campaign.name}
|
||||
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
|
||||
/>
|
||||
{quests.length === 0 ? (
|
||||
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sorted.map((q) => <QuestCard key={q.id} quest={q} />)}
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestCard({ quest }: { quest: Quest }) {
|
||||
const [q, setQ] = useState(quest);
|
||||
const [newObj, setNewObj] = useState('');
|
||||
const save = useDebouncedCallback((patch: Partial<Quest>) => void questsRepo.update(quest.id, patch), 350);
|
||||
const update = (patch: Partial<Quest>) => { setQ((p) => ({ ...p, ...patch })); save(patch); };
|
||||
|
||||
const setObjective = (id: string, patch: Partial<Objective>) =>
|
||||
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
|
||||
const addObjective = () => {
|
||||
if (newObj.trim() === '') return;
|
||||
update({ objectives: [...q.objectives, { id: newId(), text: newObj.trim(), done: false }] });
|
||||
setNewObj('');
|
||||
};
|
||||
const done = q.objectives.filter((o) => o.done).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input className="flex-1 font-display font-semibold" value={q.title} onChange={(e) => update({ title: e.target.value })} aria-label="Quest title" />
|
||||
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}>✕</Button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={q.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder="What's this quest about?"
|
||||
className="mt-2 min-h-16 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"
|
||||
/>
|
||||
|
||||
<label className="mt-2 block text-xs text-muted">
|
||||
Reward
|
||||
<Input value={q.reward} onChange={(e) => update({ reward: e.target.value })} placeholder="500 gp, a favour, a magic sword…" />
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{q.objectives.map((o) => (
|
||||
<li key={o.id} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={o.done} onChange={(e) => setObjective(o.id, { done: e.target.checked })} aria-label={o.text} />
|
||||
<input
|
||||
className={cn('flex-1 bg-transparent text-sm focus:outline-none', o.done ? 'text-muted line-through' : 'text-ink')}
|
||||
value={o.text}
|
||||
onChange={(e) => setObjective(o.id, { text: e.target.value })}
|
||||
/>
|
||||
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective">✕</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input className="h-8 text-sm" value={newObj} onChange={(e) => setNewObj(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addObjective()} placeholder="Add objective…" />
|
||||
<Button size="sm" onClick={addObjective}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { notesRepo, npcsRepo, questsRepo, calendarRepo } from '@/lib/db/repositories';
|
||||
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas';
|
||||
|
||||
export function useNotes(campaignId: string): Note[] {
|
||||
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
|
||||
}
|
||||
export function useNpcs(campaignId: string): Npc[] {
|
||||
return useLiveQuery(() => npcsRepo.listByCampaign(campaignId), [campaignId], []);
|
||||
}
|
||||
export function useQuests(campaignId: string): Quest[] {
|
||||
return useLiveQuery(() => questsRepo.listByCampaign(campaignId), [campaignId], []);
|
||||
}
|
||||
export function useCalendar(campaignId: string): Calendar | undefined {
|
||||
return useLiveQuery(() => calendarRepo.get(campaignId), [campaignId], undefined);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
|
||||
import type { Character } from '@/lib/schemas/character';
|
||||
import type { Encounter } from '@/lib/schemas/encounter';
|
||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas/world';
|
||||
import { characterDefaults } from '@/lib/schemas/character';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,10 @@ export class TtrpgDatabase extends Dexie {
|
||||
characters!: EntityTable<Character, 'id'>;
|
||||
encounters!: EntityTable<Encounter, 'id'>;
|
||||
diceRolls!: EntityTable<DiceRoll, 'id'>;
|
||||
notes!: EntityTable<Note, 'id'>;
|
||||
npcs!: EntityTable<Npc, 'id'>;
|
||||
quests!: EntityTable<Quest, 'id'>;
|
||||
calendars!: EntityTable<Calendar, 'campaignId'>;
|
||||
|
||||
constructor() {
|
||||
super('ttrpg-manager');
|
||||
@@ -49,6 +54,14 @@ export class TtrpgDatabase extends Dexie {
|
||||
if (e.log === undefined) e.log = [];
|
||||
});
|
||||
});
|
||||
|
||||
// v4 — Phase 5 worldbuilding: notes, NPCs, quests, calendar.
|
||||
this.version(4).stores({
|
||||
notes: 'id, campaignId',
|
||||
npcs: 'id, campaignId',
|
||||
quests: 'id, campaignId, status',
|
||||
calendars: 'campaignId',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { db } from './db';
|
||||
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo } from './repositories';
|
||||
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo, notesRepo, npcsRepo, questsRepo, calendarRepo } from './repositories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete();
|
||||
@@ -17,6 +17,10 @@ describe('campaign cascade delete', () => {
|
||||
});
|
||||
await encountersRepo.create(a.id, 'Death House');
|
||||
await diceRepo.add({ campaignId: a.id, label: 'attack', expression: '1d20', total: 14, breakdown: '' });
|
||||
await notesRepo.create(a.id, 'Barovia');
|
||||
await npcsRepo.create(a.id, 'Strahd');
|
||||
await questsRepo.create(a.id, 'Escape the mists');
|
||||
await calendarRepo.save({ campaignId: a.id, currentDay: 3, events: [] });
|
||||
|
||||
// unrelated campaign's data must survive
|
||||
await charactersRepo.create(b.id, {
|
||||
@@ -29,6 +33,10 @@ describe('campaign cascade delete', () => {
|
||||
expect(await charactersRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await encountersRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await diceRepo.recent(a.id)).toHaveLength(0);
|
||||
expect(await notesRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await npcsRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await questsRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await db.calendars.get(a.id)).toBeUndefined();
|
||||
|
||||
expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,19 @@ import {
|
||||
characterSchema,
|
||||
encounterSchema,
|
||||
diceRollSchema,
|
||||
noteSchema,
|
||||
npcSchema,
|
||||
questSchema,
|
||||
calendarSchema,
|
||||
type Campaign,
|
||||
type CampaignDraft,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type DiceRoll,
|
||||
type Note,
|
||||
type Npc,
|
||||
type Quest,
|
||||
type Calendar,
|
||||
} from '@/lib/schemas';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
|
||||
@@ -47,12 +55,20 @@ export const campaignsRepo = {
|
||||
|
||||
/** Delete a campaign and ALL its children atomically (real cascade). */
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.transaction('rw', db.campaigns, db.characters, db.encounters, db.diceRolls, async () => {
|
||||
await db.characters.where('campaignId').equals(id).delete();
|
||||
await db.encounters.where('campaignId').equals(id).delete();
|
||||
await db.diceRolls.where('campaignId').equals(id).delete();
|
||||
await db.campaigns.delete(id);
|
||||
});
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars],
|
||||
async () => {
|
||||
await db.characters.where('campaignId').equals(id).delete();
|
||||
await db.encounters.where('campaignId').equals(id).delete();
|
||||
await db.diceRolls.where('campaignId').equals(id).delete();
|
||||
await db.notes.where('campaignId').equals(id).delete();
|
||||
await db.npcs.where('campaignId').equals(id).delete();
|
||||
await db.quests.where('campaignId').equals(id).delete();
|
||||
await db.calendars.delete(id);
|
||||
await db.campaigns.delete(id);
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,3 +187,80 @@ export const diceRepo = {
|
||||
else await db.diceRolls.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Notes ----------------
|
||||
export const notesRepo = {
|
||||
listByCampaign(campaignId: string): Promise<Note[]> {
|
||||
return db.notes.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
get(id: string): Promise<Note | undefined> {
|
||||
return db.notes.get(id);
|
||||
},
|
||||
async create(campaignId: string, title: string): Promise<Note> {
|
||||
const ts = now();
|
||||
const note = noteSchema.parse({ id: newId(), campaignId, title, body: '', tags: [], createdAt: ts, updatedAt: ts });
|
||||
await db.notes.add(note);
|
||||
return note;
|
||||
},
|
||||
async update(id: string, patch: Partial<Omit<Note, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
||||
await db.notes.update(id, { ...patch, updatedAt: now() });
|
||||
},
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.notes.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- NPCs ----------------
|
||||
export const npcsRepo = {
|
||||
listByCampaign(campaignId: string): Promise<Npc[]> {
|
||||
return db.npcs.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
async create(campaignId: string, name: string): Promise<Npc> {
|
||||
const ts = now();
|
||||
const npc = npcSchema.parse({
|
||||
id: newId(), campaignId, name, role: '', location: '', faction: '',
|
||||
status: 'alive', description: '', createdAt: ts, updatedAt: ts,
|
||||
});
|
||||
await db.npcs.add(npc);
|
||||
return npc;
|
||||
},
|
||||
async update(id: string, patch: Partial<Omit<Npc, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
||||
await db.npcs.update(id, { ...patch, updatedAt: now() });
|
||||
},
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.npcs.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Quests ----------------
|
||||
export const questsRepo = {
|
||||
listByCampaign(campaignId: string): Promise<Quest[]> {
|
||||
return db.quests.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
async create(campaignId: string, title: string): Promise<Quest> {
|
||||
const ts = now();
|
||||
const quest = questSchema.parse({
|
||||
id: newId(), campaignId, title, status: 'active', description: '', reward: '',
|
||||
objectives: [], createdAt: ts, updatedAt: ts,
|
||||
});
|
||||
await db.quests.add(quest);
|
||||
return quest;
|
||||
},
|
||||
async update(id: string, patch: Partial<Omit<Quest, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
||||
await db.quests.update(id, { ...patch, updatedAt: now() });
|
||||
},
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.quests.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Calendar (one per campaign) ----------------
|
||||
export const calendarRepo = {
|
||||
/** Read-only — safe to call inside a Dexie liveQuery. Missing = undefined. */
|
||||
get(campaignId: string): Promise<Calendar | undefined> {
|
||||
return db.calendars.get(campaignId);
|
||||
},
|
||||
async save(calendar: Calendar): Promise<void> {
|
||||
await db.calendars.put(calendarSchema.parse(calendar));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './campaign';
|
||||
export * from './character';
|
||||
export * from './encounter';
|
||||
export * from './dice';
|
||||
export * from './world';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod';
|
||||
import { int } from './common';
|
||||
|
||||
// ---------------- Notes / Wiki ----------------
|
||||
export const noteSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().max(100000).default(''),
|
||||
tags: z.array(z.string().min(1)).default([]),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Note = z.infer<typeof noteSchema>;
|
||||
|
||||
// ---------------- NPCs ----------------
|
||||
export const npcSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
role: z.string().max(120).default(''),
|
||||
location: z.string().max(120).default(''),
|
||||
faction: z.string().max(120).default(''),
|
||||
status: z.enum(['alive', 'dead', 'unknown']).default('alive'),
|
||||
description: z.string().max(20000).default(''),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Npc = z.infer<typeof npcSchema>;
|
||||
|
||||
// ---------------- Quests ----------------
|
||||
export const objectiveSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string().min(1).max(500),
|
||||
done: z.boolean().default(false),
|
||||
});
|
||||
export type Objective = z.infer<typeof objectiveSchema>;
|
||||
|
||||
export const questSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.enum(['active', 'completed', 'failed', 'on-hold']).default('active'),
|
||||
description: z.string().max(20000).default(''),
|
||||
reward: z.string().max(500).default(''),
|
||||
objectives: z.array(objectiveSchema).default([]),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type Quest = z.infer<typeof questSchema>;
|
||||
|
||||
// ---------------- Calendar (in-world) ----------------
|
||||
// Stored as a plain integer "day number" — no real Date, so no timezone/DST bugs.
|
||||
export const calendarEventSchema = z.object({
|
||||
id: z.string(),
|
||||
day: int,
|
||||
title: z.string().min(1).max(200),
|
||||
});
|
||||
export type CalendarEvent = z.infer<typeof calendarEventSchema>;
|
||||
|
||||
export const calendarSchema = z.object({
|
||||
/** one calendar per campaign — campaignId is the primary key */
|
||||
campaignId: z.string(),
|
||||
/** current in-world day number (arbitrary epoch) */
|
||||
currentDay: int.default(0),
|
||||
events: z.array(calendarEventSchema).default([]),
|
||||
});
|
||||
export type Calendar = z.infer<typeof calendarSchema>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractWikiLinks, splitWikiLinks } from './wikilinks';
|
||||
|
||||
describe('extractWikiLinks', () => {
|
||||
it('pulls distinct titles from [[links]]', () => {
|
||||
expect(extractWikiLinks('See [[Strahd]] and [[Barovia]] and [[Strahd]] again')).toEqual(['Strahd', 'Barovia']);
|
||||
});
|
||||
it('trims whitespace and ignores empties', () => {
|
||||
expect(extractWikiLinks('a [[ Ravenloft ]] b [[]] c')).toEqual(['Ravenloft']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitWikiLinks', () => {
|
||||
it('splits text into plain and link segments', () => {
|
||||
const parts = splitWikiLinks('go to [[Town]] now');
|
||||
expect(parts).toEqual([
|
||||
{ text: 'go to ' },
|
||||
{ text: 'Town', link: 'Town' },
|
||||
{ text: ' now' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Extract the distinct titles referenced as [[Wiki Links]] in a body of text. */
|
||||
export function extractWikiLinks(text: string): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
||||
const title = m[1]?.trim();
|
||||
if (title) set.add(title);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** Split text into plain segments and [[link]] segments for rendering. */
|
||||
export function splitWikiLinks(text: string): { text: string; link?: string }[] {
|
||||
const parts: { text: string; link?: string }[] = [];
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) parts.push({ text: text.slice(last, idx) });
|
||||
parts.push({ text: m[1]!.trim(), link: m[1]!.trim() });
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < text.length) parts.push({ text: text.slice(last) });
|
||||
return parts;
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { CharacterSheetPage } from '@/features/characters/CharacterSheetPage';
|
||||
import { CombatPage } from '@/features/combat/CombatPage';
|
||||
import { DicePage } from '@/features/dice/DicePage';
|
||||
import { CompendiumPage } from '@/features/compendium/CompendiumPage';
|
||||
import { DashboardPage } from '@/features/world/DashboardPage';
|
||||
import { NotesPage } from '@/features/world/NotesPage';
|
||||
import { NpcsPage } from '@/features/world/NpcsPage';
|
||||
import { QuestsPage } from '@/features/world/QuestsPage';
|
||||
import { CalendarPage } from '@/features/world/CalendarPage';
|
||||
|
||||
const rootRoute = createRootRoute({ component: RootLayout });
|
||||
|
||||
@@ -19,14 +24,24 @@ const characterSheetRoute = createRoute({
|
||||
const combatRoute = createRoute({ getParentRoute: () => rootRoute, path: '/combat', component: CombatPage });
|
||||
const diceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dice', component: DicePage });
|
||||
const compendiumRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compendium', component: CompendiumPage });
|
||||
const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dashboard', component: DashboardPage });
|
||||
const notesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/notes', component: NotesPage });
|
||||
const npcsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/npcs', component: NpcsPage });
|
||||
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
|
||||
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
dashboardRoute,
|
||||
charactersRoute,
|
||||
characterSheetRoute,
|
||||
combatRoute,
|
||||
diceRoute,
|
||||
compendiumRoute,
|
||||
notesRoute,
|
||||
npcsRoute,
|
||||
questsRoute,
|
||||
calendarRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
|
||||
@@ -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/components/ui/RollTray.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/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.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/useRoll.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.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/check.test.ts","./src/lib/dice/check.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/conditions.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/macroStore.ts","./src/stores/rollStore.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/components/ui/RollTray.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/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.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/check.test.ts","./src/lib/dice/check.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/conditions.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/lib/schemas/world.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user