Files
ttrpg_manager/src/features/characters/CharacterSheet.tsx
T
NilsBriggen 740cf20b93 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>
2026-06-08 23:29:06 +02:00

434 lines
19 KiB
TypeScript

import { useState } from 'react';
import { Link } from '@tanstack/react-router';
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';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
import { InventorySection } from './sheet/InventorySection';
import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection';
import { AbilityGenModal } from './sheet/AbilityGenModal';
import { LevelUpModal } from './sheet/LevelUpModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
const RANK_LABEL: Record<ProficiencyRank, string> = {
untrained: 'Untrained',
trained: 'Trained',
expert: 'Expert',
master: 'Master',
legendary: 'Legendary',
};
export function CharacterSheet({ character }: { character: Character }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character);
const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
// Manual fallback: holds the link to show in a selectable dialog when neither
// Web Share nor Clipboard is available (e.g. iPad without a secure context).
const [shareLink, setShareLink] = useState<string | null>(null);
const onPortrait = async (file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
update({ portrait: thumb });
};
const shareWithPlayer = async () => {
const campaign = await campaignsRepo.get(c.campaignId);
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
// Prefer the native share sheet (best on iOS/iPadOS), then the clipboard,
// then a selectable dialog so the link is always recoverable.
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title: `${c.name} — character link`, url: link });
return;
} catch (err) {
// User dismissed the share sheet: respect that and do nothing further.
if (err instanceof DOMException && err.name === 'AbortError') return;
// Otherwise fall through to clipboard / manual fallback.
}
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link);
setShared(true);
setTimeout(() => setShared(false), 1500);
return;
}
} catch { /* clipboard blocked (e.g. insecure context on iPad) */ }
setShareLink(link);
};
const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt).
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
void charactersRepo.update(id, rest);
}, 350);
const update = (patch: Partial<Character>) => {
setC((prev) => {
const next = { ...prev, ...patch };
save(next);
return next;
});
};
const sys = getSystem(c.system);
const rulesInput: CharacterRulesInput = {
level: c.level,
abilities: c.abilities,
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus,
perceptionRank: c.perceptionRank,
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
};
const ac = sys.baseArmorClass(rulesInput);
const initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput);
const saves = sys.saveModifiers(rulesInput);
const profLabel = c.system === '5e' ? `+${sys.proficiencyValue(c.level, 'trained')} prof` : `level ${c.level}`;
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
return (
<Page>
<div className="mb-4">
<Link to="/characters" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft className="h-4 w-4" aria-hidden />
Back to characters
</Link>
</div>
{/* Header hero */}
<div className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
<div className="flex flex-wrap items-center gap-x-5 gap-y-4 p-5">
<label className="group relative h-20 w-20 shrink-0 cursor-pointer overflow-hidden rounded-2xl border border-line bg-surface-2" title="Upload portrait (also used as the map token)">
{c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
</label>
<div className="min-w-0 flex-1">
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
<Input
className="mt-0.5 max-w-md border-transparent bg-transparent px-0 font-display text-3xl font-semibold leading-tight tracking-tight focus-visible:border-line"
value={c.name}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge tone="gold">Level {c.level}</Badge>
<Badge>{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}</Badge>
<Badge tone="default">
<Award className="h-3 w-3" aria-hidden />
{profLabel}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
{[
{ icon: Shield, value: ac, label: 'Armor' },
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
{ icon: Footprints, value: c.speed, label: 'Speed' },
].map(({ icon: Ic, value, label }) => (
<div key={label} className="flex w-[4.5rem] flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center">
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
<span className="smallcaps text-[8.5px]">{label}</span>
</div>
))}
</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 ? <><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>
</div>
<div className="paper-grain mb-6 grid gap-4 rounded-xl border border-line bg-panel p-4 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
<Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</Labeled>
<Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</Labeled>
</div>
{/* Vital stats */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
<span>Armor/shield bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div>
</StatCard>
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
<HpCard c={c} update={update} />
</div>
{/* Abilities */}
<section className="mb-6">
<div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
return (
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center">
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<NumberField
className="mt-1"
value={c.abilities[a]}
min={1}
max={30}
aria-label={`${ABILITY_ABBR[a]} score`}
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })}
/>
</div>
);
})}
</div>
</section>
{/* Saves */}
<section className="mb-6">
<SectionTitle>Saving Throws</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{c.system === 'pf2e'
? PF2E_SAVES.map((s) => (
<RankRow
key={s.key}
label={s.label}
modifier={saves[s.ability]}
rank={(c.saveRanks[s.ability] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })}
system={c.system}
/>
))
: ABILITIES.map((a) => (
<RankRow
key={a}
label={`${ABILITY_ABBR[a]} save`}
modifier={saves[a]}
rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })}
system={c.system}
/>
))}
</div>
</section>
{/* Skills */}
<section className="mb-6">
<SectionTitle>Skills</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((s) => (
<RankRow
key={s.key}
label={`${s.label} (${ABILITY_ABBR[s.ability]})`}
modifier={s.modifier}
rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })}
system={c.system}
/>
))}
</div>
</section>
{/* Status & defenses (system-specific) */}
<DefensesSection c={c} update={update} />
{/* Attacks + passive perception */}
<AttacksSection c={c} update={update} />
{/* Spellcasting */}
<SpellcastingSection c={c} update={update} />
{/* Class resources + rest */}
<ResourcesSection c={c} update={update} />
{/* Inventory, currency, encumbrance */}
<InventorySection c={c} update={update} />
{/* Notes */}
<section className="mb-6">
<SectionTitle>Notes</SectionTitle>
<textarea
value={c.notes}
onChange={(e) => update({ notes: e.target.value })}
placeholder="Backstory, bonds, reminders…"
className="min-h-32 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"
/>
</section>
{genScores && (
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} />
)}
{levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
)}
{shareLink !== null && (
<Modal
open
onClose={() => setShareLink(null)}
title="Share with player"
footer={<Button size="sm" variant="ghost" onClick={() => setShareLink(null)}>Done</Button>}
>
<p className="mb-2 text-sm text-muted">Copy this link and send it to the player:</p>
<Input
data-autofocus
readOnly
value={shareLink}
aria-label="Player link"
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
</Modal>
)}
</Page>
);
}
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [delta, setDelta] = useState(0);
const apply = (mode: 'damage' | 'heal') => {
if (!Number.isFinite(delta) || delta <= 0) return;
if (mode === 'damage') {
const absorbed = Math.min(c.hp.temp, delta);
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
} else {
update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } });
}
setDelta(0);
};
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span>
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div>
<div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
<label>Temp<NumberField value={c.hp.temp} min={0} onChange={(temp) => update({ hp: { ...c.hp, temp } })} aria-label="Temp HP" /></label>
</div>
<div className="mt-2 flex items-center gap-1">
<NumberField className="w-16" value={delta} min={0} onChange={setDelta} aria-label="HP change amount" />
<Button size="sm" variant="danger" className="flex-1" onClick={() => apply('damage')}>
Damage
</Button>
<Button size="sm" variant="secondary" className="flex-1" onClick={() => apply('heal')}>
Heal
</Button>
</div>
</div>
);
}
function RankRow({
label,
modifier,
rank,
ranks,
onRank,
system,
}: {
label: string;
modifier: number;
rank: ProficiencyRank;
ranks: ProficiencyRank[];
onRank: (r: ProficiencyRank) => void;
system: SystemId;
}) {
const proficient = rank !== 'untrained';
return (
<div className="flex items-center gap-2 rounded-lg border border-line bg-panel px-3 py-1.5">
<span
className={cn('h-2 w-2 shrink-0 rounded-full', proficient ? 'bg-accent' : 'bg-line-strong')}
aria-hidden
/>
<button
onClick={() => rollCheck(modifier, label, { system })}
className="w-9 rounded font-mono font-display text-lg font-semibold text-accent-deep hover:bg-accent/10"
title={`Roll ${label}`}
>
{formatModifier(modifier)}
</button>
<span className="flex-1 truncate text-sm text-ink">{label}</span>
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
{ranks.map((r) => (
<option key={r} value={r}>
{RANK_LABEL[r]}
</option>
))}
</Select>
</div>
);
}
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="smallcaps mb-1 block">{label}</span>
{children}
</label>
);
}
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">{label}</div>
<div className="my-1 font-mono font-display text-3xl font-semibold text-accent-deep">{value}</div>
{hint && <div className="text-xs text-muted">{hint}</div>}
{children}
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
}