UI/UX "Living Codex" 4/n: assistant features + emoji purge
- Replace all emoji icons (~26 instances) with Lucide icons across the app (DashboardPage, SessionSidebar, PlayerBoards, MyCharacterPanel, PlayerViewPage, EncounterTracker, CampaignsPage, HandoutControl, SessionControl, CharacterSheet, EncounterTipCard, ActionGuide) - Expose real 5e race data in wizard (ASI/vision/traits instead of stub desc); add vision field to loadRaces5e return type - Surface subclass descriptions after picking a subclass in the wizard - Add per-class tips, race synergy notes, and ability/skill guidance in wizard - New ActionGuide component: collapsible "What can I do on my turn?" in player view - New SessionPrepCard: AI + fallback session hook generator on assistant page - New NpcGenCard: AI + fallback quick NPC generator with "Add to campaign" action - Inline NPC detail generator (wand button) on each NPC card in NpcsPage - Quest hook generator button in QuestsPage header - Condition tooltips in player party view (useConditionGlossary) - Pass campaign.system through session snapshot so player view is system-aware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { CampaignInsights } from './CampaignInsights';
|
||||
import { SessionPrepCard } from './SessionPrepCard';
|
||||
import { NpcGenCard } from './NpcGenCard';
|
||||
|
||||
export function AssistantPage() {
|
||||
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
|
||||
@@ -128,6 +130,12 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
|
||||
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
||||
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session hooks</h2>
|
||||
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
|
||||
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Quick NPC</h2>
|
||||
<NpcGenCard campaign={campaign} />
|
||||
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
|
||||
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Brain } from 'lucide-react';
|
||||
import type { Campaign, Encounter } from '@/lib/schemas';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
|
||||
data-testid="encounter-tip"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg" aria-hidden>🧠</span>
|
||||
<Brain size={18} className="shrink-0 text-muted" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
{theme ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { npcsRepo } from '@/lib/db/repositories';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
interface Props {
|
||||
campaign: Campaign;
|
||||
}
|
||||
|
||||
export function NpcGenCard({ campaign }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
|
||||
const [hint, setHint] = useState('');
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
const [npc, setNpc] = useState<NpcQuick | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const ctx = {
|
||||
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
|
||||
systemLabel: getSystem(campaign.system).label,
|
||||
questsSummary: 'No active quests.',
|
||||
notesSummary: 'No notes yet.',
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
setState('loading');
|
||||
setMessage(null);
|
||||
|
||||
if (canUseLlm) {
|
||||
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
|
||||
const result = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
|
||||
if (result.ok && 'data' in result) {
|
||||
setNpc(result.data);
|
||||
setState('ready');
|
||||
return;
|
||||
}
|
||||
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template NPC instead.`);
|
||||
}
|
||||
|
||||
setNpc(fallbackNpc());
|
||||
setState('ready');
|
||||
};
|
||||
|
||||
const addToCampaign = async () => {
|
||||
if (!npc) return;
|
||||
const created = await npcsRepo.create(campaign.id, npc.name);
|
||||
await npcsRepo.update(created.id, {
|
||||
role: npc.role,
|
||||
description: `Motivation: ${npc.motivation}\n\nSecret: ${npc.secret}\n\nIntro hook: ${npc.hook}`,
|
||||
});
|
||||
void navigate({ to: '/npcs' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
Generate a ready-to-use NPC with motivation, secret, and an intro hook for the table.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={hint}
|
||||
onChange={(e) => setHint(e.target.value)}
|
||||
placeholder={`Optional: "a nervous blacksmith" or "a corrupt city guard"`}
|
||||
className="flex-1 text-sm"
|
||||
aria-label="NPC hint"
|
||||
/>
|
||||
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
|
||||
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Generate (AI)' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
|
||||
|
||||
{state === 'ready' && npc && (
|
||||
<div className="mt-4 rounded-md border border-line bg-surface-2 p-3 text-sm">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-display text-lg font-semibold text-ink">{npc.name}</span>
|
||||
<span className="ml-2 text-xs text-muted">{npc.role}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="primary" onClick={() => void addToCampaign()}>
|
||||
Add to campaign
|
||||
</Button>
|
||||
</div>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<NpcField label="Motivation" value={npc.motivation} />
|
||||
<NpcField label="Secret" value={npc.secret} />
|
||||
<NpcField label="Intro hook" value={npc.hook} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="inline font-semibold text-ink">{label}: </dt>
|
||||
<dd className="inline text-muted">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import type { Campaign, Character, Note, Quest } from '@/lib/schemas';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||
import { buildSessionHookPrompt, sessionHookSchema, fallbackSessionHooks, type SessionHook } from '@/lib/assistant/session';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface Props {
|
||||
campaign: Campaign;
|
||||
characters: Character[];
|
||||
notes: Note[];
|
||||
quests: Quest[];
|
||||
}
|
||||
|
||||
export function SessionPrepCard({ campaign, characters, notes, quests }: Props) {
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
const [hooks, setHooks] = useState<SessionHook[]>([]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const generate = async () => {
|
||||
setState('loading');
|
||||
setMessage(null);
|
||||
const ctx = buildCampaignContext({ campaign, characters, encounters: [], quests, notes });
|
||||
|
||||
if (canUseLlm) {
|
||||
const { system, user } = buildSessionHookPrompt(ctx);
|
||||
const result = await complete<{ hooks: SessionHook[] }>(getLlmConfig(), { system, user, schema: sessionHookSchema });
|
||||
if (result.ok && 'data' in result) {
|
||||
setHooks(result.data.hooks);
|
||||
setState('ready');
|
||||
return;
|
||||
}
|
||||
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template hooks instead.`);
|
||||
}
|
||||
|
||||
setHooks(fallbackSessionHooks(ctx));
|
||||
setState('ready');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
Generate three distinct session opening hooks grounded in your campaign's current threads.
|
||||
</p>
|
||||
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
|
||||
{state === 'loading' ? 'Thinking…' : state === 'ready' ? '↻ Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
|
||||
</Button>
|
||||
|
||||
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
|
||||
|
||||
{state === 'ready' && hooks.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{hooks.map((h, i) => (
|
||||
<HookCard key={i} hook={h} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HookCard({ hook }: { hook: SessionHook }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard?.writeText(`${hook.title}\n\n${hook.opening}\n\n${hook.tension}`).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-2 p-3 text-sm">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<span className="font-display font-semibold text-ink">{hook.title}</span>
|
||||
<button onClick={copy} title="Copy hook" className="shrink-0 text-muted hover:text-ink">
|
||||
{copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted">{hook.opening}</p>
|
||||
<p className="mt-1.5 text-xs italic text-faint">{hook.tension}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
|
||||
import { ArrowRight, BookOpenText, CalendarDays, Map, RadioTower, ScrollText, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { campaignsRepo } from '@/lib/db/repositories';
|
||||
import { seedSampleCampaign } from '@/lib/sample';
|
||||
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
||||
</p>
|
||||
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
|
||||
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
|
||||
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
|
||||
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
||||
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
|
||||
<FeatCard icon={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
|
||||
<FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
||||
<FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
|
||||
@@ -83,7 +83,7 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
|
||||
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
|
||||
<div className="text-xl" aria-hidden>{icon}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
@@ -161,7 +161,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
|
||||
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <><Check size={12} aria-hidden /> Link copied</> : 'Share with player'}</Button>}
|
||||
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
@@ -12,8 +13,10 @@ import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { Field, Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -33,16 +36,16 @@ function playstyle(c: RulesetClass): string {
|
||||
|
||||
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
|
||||
'5e': [
|
||||
{ label: '🛡️ Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
|
||||
{ label: '🔥 Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
|
||||
{ label: '🗡️ Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
|
||||
{ label: '✨ Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
|
||||
{ label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
|
||||
{ label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
|
||||
{ label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
|
||||
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
|
||||
],
|
||||
pf2e: [
|
||||
{ label: '🛡️ Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
|
||||
{ label: '🔥 Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
|
||||
{ label: '🗡️ Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
|
||||
{ label: '✨ Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
|
||||
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
|
||||
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
|
||||
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
|
||||
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -65,10 +68,37 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
if (system === '5e') {
|
||||
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
|
||||
void loadRaces5e().then((rs) => {
|
||||
if (!on) return;
|
||||
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
|
||||
setOrigins(rs.map((r) => {
|
||||
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
|
||||
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
|
||||
const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
|
||||
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
|
||||
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
|
||||
return { name: r.name, desc, meta };
|
||||
}));
|
||||
});
|
||||
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
|
||||
} else {
|
||||
void loadPf2e('ancestries').then((rs) => on && setOrigins(rs.map((r) => ({ name: String(r.name), desc: String((r.description ?? r.text) ?? ''), ...(typeof r.hp === 'number' ? { hp: r.hp } : {}), ...((r.speed as { land?: number } | undefined)?.land ? { speed: (r.speed as { land: number }).land } : {}) }))));
|
||||
void loadPf2e('ancestries').then((rs) => {
|
||||
if (!on) return;
|
||||
setOrigins(rs.map((r) => {
|
||||
const hp = typeof r.hp === 'number' ? r.hp : undefined;
|
||||
const speed = (r.speed as { land?: number } | undefined)?.land;
|
||||
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
|
||||
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
|
||||
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
|
||||
return {
|
||||
name: String(r.name),
|
||||
desc: String((r.summary ?? r.text) ?? ''),
|
||||
...(hp !== undefined ? { hp } : {}),
|
||||
...(speed ? { speed } : {}),
|
||||
meta,
|
||||
};
|
||||
}));
|
||||
});
|
||||
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
|
||||
}
|
||||
return () => { on = false; };
|
||||
@@ -285,21 +315,61 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedClass && selectedClass.subclasses.length > 0 && (
|
||||
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
||||
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
|
||||
<option value="">— decide later —</option>
|
||||
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
{selectedClass && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
if (!tip) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb size={15} className="mt-0.5 shrink-0 text-accent" aria-hidden />
|
||||
<div>
|
||||
<div className="font-medium text-ink">{selectedClass.name}</div>
|
||||
<p className="mt-0.5 text-muted">{tip.role}</p>
|
||||
<div className="mt-1.5 text-xs text-muted">
|
||||
<span className="font-medium text-ink">Key abilities:</span> {tip.primaryAbilities}
|
||||
</div>
|
||||
{tip.beginner && <Badge tone="verdigris" className="mt-1.5">Good for beginners</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selectedClass && selectedClass.subclasses.length > 0 && (() => {
|
||||
const sc = selectedClass.subclasses.find((s) => s.name === subclass);
|
||||
return (
|
||||
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
||||
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
|
||||
<option value="">— decide later —</option>
|
||||
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</Select>
|
||||
{sc?.desc && (
|
||||
<p className="mt-1 max-h-28 overflow-y-auto rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
|
||||
{sc.desc}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepName === 'Origin' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||
</div>
|
||||
{selectedClass && selectedOrigin && (() => {
|
||||
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
|
||||
if (!synergy) return null;
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-xs text-accent">
|
||||
<Lightbulb size={12} className="shrink-0" aria-hidden />
|
||||
{synergy}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -312,7 +382,18 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}>↻ Reroll</Button>}
|
||||
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
|
||||
{selectedClass && selectedClass.keyAbilities.length > 0 && <p className="mb-3 text-xs text-muted">Tip: prioritise <span className="text-ink">{selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')}</span> for a {selectedClass.name}.</p>}
|
||||
{selectedClass && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
|
||||
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
|
||||
: null);
|
||||
return text ? (
|
||||
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
|
||||
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
|
||||
{text}
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{ABILITIES.map((a, i) => {
|
||||
@@ -339,7 +420,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
{stepName === 'Skills' && (
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
|
||||
<p className="mb-3 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
|
||||
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
|
||||
{selectedClass && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
return tip?.skillSuggestions ? (
|
||||
<p className="mb-3 flex items-center gap-1.5 text-xs text-muted">
|
||||
<Lightbulb size={12} className="shrink-0 text-accent" aria-hidden />
|
||||
{selectedClass.name}s often pick: <span className="ml-1 text-ink">{tip.skillSuggestions}</span>
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{skillOptions.map((key) => {
|
||||
const checked = skills.includes(key);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Dices,
|
||||
@@ -548,7 +549,7 @@ function ConditionPicker({
|
||||
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
|
||||
{cd.name}
|
||||
{cd.valued ? ' (#)' : ''}
|
||||
{taken.has(cd.name) ? ' ✓' : ''}
|
||||
{taken.has(cd.name) ? <Check size={12} aria-hidden /> : ''}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">Custom…</option>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Megaphone } from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -21,7 +22,7 @@ export function HandoutControl() {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
|
||||
{active ? '📣 Handout ✓' : '📣 Handout'}
|
||||
<Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
|
||||
</Button>
|
||||
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout">✕</Button>}
|
||||
{open && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { RadioTower, X } from 'lucide-react';
|
||||
import { Check, Lock, RadioTower, X } from 'lucide-react';
|
||||
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
|
||||
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
@@ -28,7 +28,7 @@ export function SessionControl() {
|
||||
if (!cloudUsername()) {
|
||||
return (
|
||||
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
|
||||
🔒 Sign in to host
|
||||
<Lock size={14} aria-hidden /> Sign in to host
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function SessionControl() {
|
||||
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
|
||||
hostSession(campaign.id, pw || undefined);
|
||||
}}
|
||||
>📡 Host</Button>
|
||||
><RadioTower size={14} aria-hidden /> Host</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function SessionControl() {
|
||||
className="font-mono font-semibold tracking-wider text-accent-deep"
|
||||
title="Copy player link"
|
||||
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
|
||||
>{joinCode}{copied ? ' ✓' : ''}</button>
|
||||
>{joinCode}{copied ? <><Check size={11} aria-hidden /></> : ''}</button>
|
||||
|
||||
{seatRequests.length > 0 && (
|
||||
<button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
|
||||
import type { SessionLogEntry } from '@/lib/schemas';
|
||||
import type { RosterEntry } from '@/lib/sync/messages';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Dice5, Mail, MessageSquare } from 'lucide-react';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -109,7 +110,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
|
||||
{isGm && p.playerId !== 'gm' && (
|
||||
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
|
||||
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info"><Mail size={12} aria-hidden /></button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{(recap ?? []).map((e) => (
|
||||
<li key={e.id} className="flex gap-2">
|
||||
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'}>{e.kind === 'roll' ? '🎲' : '💬'}</span>
|
||||
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'} aria-hidden>{e.kind === 'roll' ? <Dice5 size={12} /> : <MessageSquare size={12} />}</span>
|
||||
<span className="text-muted">{e.from}:</span>
|
||||
<span className="min-w-0 flex-1 text-ink">{e.text}</span>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
|
||||
|
||||
/** Collapsible "What can I do?" guide for the player in-session view. */
|
||||
export function ActionGuide({ character }: { character: Character }) {
|
||||
const guide = getTurnGuide(character.system, character.className?.toLowerCase() ?? '') ?? getGenericGuide(character.system);
|
||||
|
||||
return (
|
||||
<details className="mt-3 rounded-lg border border-line bg-surface-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-muted hover:text-ink">
|
||||
What can I do on my turn? ▸
|
||||
</summary>
|
||||
<div className="border-t border-line px-3 pb-3 pt-2">
|
||||
<ul className="space-y-2">
|
||||
{guide.actions.map((a) => (
|
||||
<li key={a.label}>
|
||||
<span className="text-xs font-semibold text-ink">{a.label}: </span>
|
||||
<span className="text-xs text-muted">{a.desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
|
||||
<p className="mt-2 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
|
||||
↑ {guide.upgradeAt.note}
|
||||
</p>
|
||||
)}
|
||||
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
|
||||
<p className="mt-2 rounded-md border border-success/30 bg-success/5 px-2 py-1 text-xs text-success">
|
||||
<Check size={12} className="mr-1 inline" aria-hidden /> {guide.upgradeAt.note}
|
||||
</p>
|
||||
)}
|
||||
{guide.tip && (
|
||||
<p className="mt-2 border-t border-line pt-2 text-xs italic text-muted">{guide.tip}</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { Character, Defenses } from '@/lib/schemas';
|
||||
import { ActionGuide } from './ActionGuide';
|
||||
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
@@ -61,7 +62,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Pips label="Death saves ✓" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
|
||||
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
|
||||
<Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
|
||||
<div className="flex items-center justify-between">
|
||||
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
|
||||
@@ -114,6 +115,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
{/* Dice */}
|
||||
<DiceBox characterId={c.id} />
|
||||
</div>
|
||||
<ActionGuide character={c} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { Snapshot } from '@/lib/sync/messages';
|
||||
import type { PlayerMap } from '@/lib/map';
|
||||
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { EmptyState } from '@/components/ui/Page';
|
||||
import { Meter, Badge } from '@/components/ui/Codex';
|
||||
@@ -11,6 +13,7 @@ import { PlayerMapView } from './PlayerMapView';
|
||||
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
||||
const { party, encounter, map, handout } = snapshot;
|
||||
const quests = snapshot.quests ?? [];
|
||||
const condGlossary = useConditionGlossary((snapshot.system ?? '5e') as SystemId);
|
||||
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
||||
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
||||
return (
|
||||
@@ -18,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
||||
{privateHandout && (
|
||||
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
|
||||
<div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
|
||||
<Mail size={13} aria-hidden /> 📨 Just for you
|
||||
<Mail size={13} aria-hidden /> Just for you
|
||||
</div>
|
||||
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
|
||||
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
|
||||
@@ -74,9 +77,12 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
||||
<div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
||||
{c.conditions.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{c.conditions.map((cond, i) => (
|
||||
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||
))}
|
||||
{c.conditions.map((cond, i) => {
|
||||
const tooltip = condGlossary.get(cond.name.toLowerCase());
|
||||
return (
|
||||
<span key={i} title={tooltip || undefined} className="cursor-help rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { useCalendar, useMaps } from '@/features/world/hooks';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PlayerBoards } from './PlayerBoards';
|
||||
@@ -47,7 +48,7 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
|
||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
||||
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"><Maximize2 size={14} aria-hidden /> Fullscreen</Button>
|
||||
</div>
|
||||
<hr className="gilt-rule mt-3" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
|
||||
import {
|
||||
Brain, Users, Swords, ScrollText, VenetianMask, Target, Map, CalendarDays, Library, Hammer, Dice5, Monitor,
|
||||
ChevronRight, Shield, CheckCheck, Skull, Handshake,
|
||||
} from 'lucide-react';
|
||||
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
|
||||
import { diceRepo } from '@/lib/db/repositories';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
@@ -15,20 +18,21 @@ export function DashboardPage() {
|
||||
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/assistant', label: 'Assistant', icon: '🧠' },
|
||||
{ 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: '/maps', label: 'Maps', icon: '🗺️' },
|
||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
||||
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
|
||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
||||
{ to: '/play', label: 'Player View', icon: '📺' },
|
||||
] as const;
|
||||
type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
|
||||
const LINKS: { to: string; label: string; icon: NavIcon }[] = [
|
||||
{ to: '/assistant', label: 'Assistant', icon: Brain },
|
||||
{ to: '/characters', label: 'Characters', icon: Users },
|
||||
{ to: '/combat', label: 'Combat', icon: Swords },
|
||||
{ to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
|
||||
{ to: '/npcs', label: 'NPCs', icon: VenetianMask },
|
||||
{ to: '/quests', label: 'Quests', icon: Target },
|
||||
{ to: '/maps', label: 'Maps', icon: Map },
|
||||
{ to: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||
{ to: '/compendium', label: 'Compendium', icon: Library },
|
||||
{ to: '/homebrew', label: 'Homebrew', icon: Hammer },
|
||||
{ to: '/dice', label: 'Dice', icon: Dice5 },
|
||||
{ to: '/play', label: 'Player View', icon: Monitor },
|
||||
];
|
||||
|
||||
function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||
to={l.to}
|
||||
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
|
||||
>
|
||||
<span className="text-2xl" aria-hidden>{l.icon}</span>
|
||||
<l.icon size={24} className="text-muted" aria-hidden />
|
||||
<span className="text-sm text-ink">{l.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import type { Campaign, Npc } from '@/lib/schemas';
|
||||
import { npcsRepo } from '@/lib/db/repositories';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { useNpcs } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
@@ -33,7 +38,7 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
||||
<>
|
||||
<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} />)}
|
||||
{filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -41,13 +46,39 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NpcCard({ npc }: { npc: Npc }) {
|
||||
function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
|
||||
const [n, setN] = useState(npc);
|
||||
const [genState, setGenState] = useState<'idle' | 'loading'>('idle');
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
|
||||
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
|
||||
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
|
||||
}), 350);
|
||||
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
||||
|
||||
const generateDetails = async () => {
|
||||
setGenState('loading');
|
||||
const ctx = {
|
||||
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
|
||||
systemLabel: getSystem(campaign.system).label,
|
||||
questsSummary: 'No active quests.',
|
||||
notesSummary: 'No notes yet.',
|
||||
};
|
||||
const hint = [n.name, n.role].filter(Boolean).join(', ');
|
||||
let result: NpcQuick | null = null;
|
||||
if (canUseLlm) {
|
||||
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
|
||||
const res = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
|
||||
if (res.ok && 'data' in res) result = res.data;
|
||||
}
|
||||
if (!result) result = fallbackNpc();
|
||||
const desc = `Motivation: ${result.motivation}\n\nSecret: ${result.secret}\n\nIntro hook: ${result.hook}`;
|
||||
update({ description: desc, role: result.role || n.role });
|
||||
setGenState('idle');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -55,6 +86,9 @@ function NpcCard({ npc }: { npc: Npc }) {
|
||||
<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="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
|
||||
<Wand2 size={13} aria-hidden />
|
||||
</Button>
|
||||
<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">
|
||||
|
||||
@@ -2,8 +2,15 @@ import { useState } from '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';
|
||||
@@ -20,15 +27,51 @@ export function QuestsPage() {
|
||||
|
||||
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={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
|
||||
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>} />
|
||||
|
||||
Reference in New Issue
Block a user