f2e4259c84
- New themed useConfirm() hook (no native dialogs). World deletes (notes, quests, homebrew, maps) now confirm before removing. - Dice 'Clear history' is disabled with no active campaign (it used to wipe EVERY campaign's rolls) and now confirms; only ever clears the active campaign. - sessionLog is included in backup/restore and the campaign cascade-delete — it was silently dropped on backup and orphaned on campaign delete. - Unknown URLs render a themed 'Page not found' with a link home instead of a blank shell. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
7.4 KiB
TypeScript
156 lines
7.4 KiB
TypeScript
|
||
import { useState } from 'react';
|
||
import { X } from 'lucide-react';
|
||
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
||
import { questsRepo } from '@/lib/db/repositories';
|
||
import { newId } from '@/lib/ids';
|
||
import { complete } from '@/lib/llm/client';
|
||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||
import { buildQuestHookPrompt, questHookSchema, fallbackQuestHook, type QuestHook } from '@/lib/assistant/session';
|
||
import { buildCampaignContext } from '@/lib/assistant/context';
|
||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||
import { useQuests } from './hooks';
|
||
import { useCharacters } from '@/features/characters/hooks';
|
||
import { useNotes } from '@/features/world/hooks';
|
||
import { useEncounters } from '@/features/combat/hooks';
|
||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Input, Select } from '@/components/ui/Input';
|
||
import { useConfirm } from '@/components/ui/useConfirm';
|
||
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 characters = useCharacters(campaign.id);
|
||
const notes = useNotes(campaign.id);
|
||
const encounters = useEncounters(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));
|
||
const [genBusy, setGenBusy] = useState(false);
|
||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||
const canUseLlm = llmEnabled && hasKey;
|
||
|
||
const generateQuest = async () => {
|
||
setGenBusy(true);
|
||
try {
|
||
let hook: QuestHook | null = null;
|
||
if (canUseLlm) {
|
||
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
|
||
const { system, user } = buildQuestHookPrompt(ctx);
|
||
const res = await complete<QuestHook>(getLlmConfig(), { system, user, schema: questHookSchema });
|
||
if (res.ok && 'data' in res) hook = res.data;
|
||
}
|
||
if (!hook) hook = fallbackQuestHook();
|
||
const created = await questsRepo.create(campaign.id, hook.title);
|
||
await questsRepo.update(created.id, {
|
||
description: hook.description,
|
||
reward: hook.reward,
|
||
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
|
||
});
|
||
} finally {
|
||
setGenBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Page>
|
||
<PageHeader
|
||
title="Quests"
|
||
subtitle={campaign.name}
|
||
actions={
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" disabled={genBusy} onClick={() => void generateQuest()}>
|
||
{genBusy ? 'Generating…' : 'Generate quest hook'}
|
||
</Button>
|
||
<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
{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 { confirm, confirmElement } = useConfirm();
|
||
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
|
||
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
|
||
}), 350);
|
||
const update = (patch: Partial<Quest>) => setQ((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
||
|
||
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={async () => { if (await confirm({ title: 'Delete quest?', message: <>Delete <strong className="text-ink">{q.title || 'this quest'}</strong>? This can’t be undone.</> })) void questsRepo.remove(quest.id); }} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
|
||
</div>
|
||
{confirmElement}
|
||
|
||
<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 smallcaps">
|
||
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"><X size={13} aria-hidden /></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>
|
||
);
|
||
}
|