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 = { 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); 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(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) => { 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, saveRanks: c.saveRanks as Partial>, 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 (
Back to characters
{/* Header hero */}
{c.ancestry || sys.label}
update({ name: e.target.value })} />
Level {c.level} {c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label} {profLabel}
{[ { 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 }) => (
{value} {label}
))}
{c.kind === 'pc' && }
update({ ancestry: e.target.value })} /> update({ className: e.target.value })} /> update({ level })} /> update({ speed })} />
{/* Vital stats */}
Armor/shield bonus update({ armorBonus })} aria-label="Armor bonus" />
{/* Abilities */}
Ability Scores
{ABILITIES.map((a) => { const mod = sys.abilityModifier(c.abilities[a]); return (
{ABILITY_ABBR[a]}
{formatModifier(mod)}
update({ abilities: { ...c.abilities, [a]: v } })} />
); })}
{/* Saves */}
Saving Throws
{c.system === 'pf2e' ? PF2E_SAVES.map((s) => ( update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })} system={c.system} /> )) : ABILITIES.map((a) => ( update({ saveRanks: { ...c.saveRanks, [a]: rank } })} system={c.system} /> ))}
{/* Skills */}
Skills
{skills.map((s) => ( update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })} system={c.system} /> ))}
{/* Status & defenses (system-specific) */} {/* Attacks + passive perception */} {/* Spellcasting */} {/* Class resources + rest */} {/* Inventory, currency, encumbrance */} {/* Notes */}
Notes