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:
@@ -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