Files
ttrpg_manager/src/features/world/QuestsPage.tsx
T
NilsBriggen f2e4259c84 UX safety: confirm destructive deletes, scope dice clear, back up sessionLog, add 404
- 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>
2026-06-09 17:37:52 +02:00

156 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 cant 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>
);
}