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 { cn } from '@/lib/cn'; const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed']; const STATUS_COLOR: Record = { active: 'text-info', 'on-hold': 'text-muted', completed: 'text-success', failed: 'text-danger', }; export function QuestsPage() { return {(c) => }; } 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(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 ( } /> {quests.length === 0 ? ( questsRepo.create(campaign.id, 'New quest')}>+ New quest} /> ) : (
{sorted.map((q) => )}
)}
); } function QuestCard({ quest }: { quest: Quest }) { const [q, setQ] = useState(quest); const [newObj, setNewObj] = useState(''); 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) => setQ((p) => { const next = { ...p, ...patch }; save(next); return next; }); const setObjective = (id: string, patch: Partial) => 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 (
update({ title: e.target.value })} aria-label="Quest title" />