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