740cf20b93
- 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>
434 lines
19 KiB
TypeScript
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>;
|
|
}
|