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