Files
the-sunken-reach/frontend/src/pages/CharacterSheetPage.tsx
T
joshuaandClaude Opus 5.5 932e755895 V1.3.0: Hollowcrown seed, player visibility rules, initiative rolls, number limits, temp HP
- Hollowcrown: The Rift Between Worlds as its own seed (npm run
  seed:hollowcrown), built from the campaign book: pools, 58 monsters,
  57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared
  seeding logic moved to prisma/seedCampaign.js.
- What players can see: only encounters their own character is in; only
  the pool entries their character holds (plus starting options while
  building); only locations their character has been to; no invite code;
  no gold, inventory or money log of other players' characters. Socket
  events carry ids only and campaign rooms are members-only.
- Encounters have a location; while one is in progress its characters are
  there, and visits are remembered.
- Campaign setting to show players a health bar only for monsters and NPCs.
- Initiative is entered as the d20 roll (1-20) and the bonus is added;
  players can enter their own character's roll.
- Number fields enforce their limits when typing, and the API enforces
  the same limits.
- Temp HP shows on HP bars and absorbs damage before real HP.
- All packages at version 1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:53:48 +02:00

929 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useCharacter,
usePatchCharacter,
useCampaignWeapons,
useAssignWeapon,
usePatchCharacterWeapon,
useRemoveCharacterWeapon,
useCampaignItems,
useAssignItem,
usePatchInventoryItem,
useRemoveInventoryItem,
useCampaignSpells,
useAssignSpell,
usePatchCharacterSpell,
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useCampaignCharacters,
useGive,
type Gift,
useCharacterNotes,
useCreateNote,
useCampaign,
useCampaigns,
useCopyCharacter,
useCampaignLocations,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
import type { Ability, CharacterDetail } from '../api/types';
// The DM's level picker for one held weapon or item, from the level the gear
// starts at up to the maximum. Each option shows what the dice become at that
// level, so the step up is visible before choosing.
function GearLevelSelect({
name,
level,
entry,
onChange,
}: {
name: string;
level: number;
entry: { damageDice: string | null; damageScaling: number; minLevel: number };
onChange: (level: number) => void;
}) {
const levels = Array.from({ length: MAX_GEAR_LEVEL - entry.minLevel + 1 }, (_, i) => entry.minLevel + i);
return (
<select aria-label={`${name} level`} value={level} onChange={(e) => onChange(Number(e.target.value))}>
{levels.map((l) => (
<option key={l} value={l}>
Lv {l}
{entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, l, entry.damageScaling, entry.minLevel)}` : ''}
</option>
))}
</select>
);
}
function RequiresLevel({ level }: { level: number }) {
return (
<span className="badge" style={{ marginLeft: 6 }} title="Held, but can't be used until the character reaches this level">
Requires level {level}
</span>
);
}
// Lets the owner (or DM) hand something this character holds to another
// character in the campaign. Giving moves it: the giver no longer has it.
function GiveCard({ character, onError }: { character: CharacterDetail; onError: (err: unknown) => void }) {
const { data: characters } = useCampaignCharacters(character.campaignId);
const give = useGive(character.id);
const [what, setWhat] = useState('');
const [amount, setAmount] = useState('1');
const [toCharacterId, setToCharacterId] = useState('');
const [done, setDone] = useState<string | null>(null);
const others = characters?.filter((c) => c.id !== character.id && c.buildStage === 'COMPLETE') ?? [];
if (others.length === 0) return null;
// "kind:id" identifies the thing picked; gold has no id.
const [kind, id] = what.split(':');
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
const gold = character.gold ?? 0;
const max = kind === 'gold' ? gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const count = Math.floor(Number(amount));
const countOk = kind === 'spell' || (count >= 1 && count <= max);
const nothingToGive = gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
function handleGive() {
const gift: Gift | null =
kind === 'gold'
? { kind, amount: count }
: kind === 'weapon' || kind === 'item'
? { kind, id, quantity: count }
: kind === 'spell'
? { kind, id }
: null;
if (!gift || !toCharacterId) return;
setDone(null);
give.mutate(
{ ...gift, toCharacterId },
{
onError,
onSuccess: () => {
setDone(`Given to ${others.find((c) => c.id === toCharacterId)?.name}.`);
setWhat('');
setAmount('1');
},
}
);
}
return (
<div className="card mt-md">
<div className="card-title">Give to Another Character</div>
<p className="subtitle" style={{ marginTop: 0 }}>
Hand gold, a weapon, an item or a spell to someone else in the party. Giving moves it — {character.name} no longer has
it afterwards.
</p>
{nothingToGive ? (
<p style={{ color: 'var(--text-muted)', margin: 0 }}>{character.name} has nothing to give yet.</p>
) : (
<div className="flex-row flex-wrap">
<select
aria-label="What to give"
value={what}
onChange={(e) => {
setWhat(e.target.value);
setAmount('1');
setDone(null);
}}
style={{ flex: 2, minWidth: 160 }}
>
<option value="">What to give…</option>
{gold > 0 && <option value="gold:">Gold ({gold} gp)</option>}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
<option key={w.weaponId} value={`weapon:${w.weaponId}`}>
{w.weapon.name} (Lv {w.level}, x{w.quantity})
</option>
))}
</optgroup>
)}
{character.inventoryItems.length > 0 && (
<optgroup label="Items">
{character.inventoryItems.map((i) => (
<option key={i.itemId} value={`item:${i.itemId}`}>
{i.item.name} (Lv {i.level}, x{i.quantity})
</option>
))}
</optgroup>
)}
{character.spells.length > 0 && (
<optgroup label="Spells">
{character.spells.map((s) => (
<option key={s.spellId} value={`spell:${s.spellId}`}>
{s.spell.name} ({spellLevelLabel(s.spell.level)})
</option>
))}
</optgroup>
)}
</select>
{what && kind !== 'spell' && (
<NumberInput
min={1}
max={max}
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
title={kind === 'gold' ? 'How much gold' : `How many (up to ${max})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
/>
)}
<select aria-label="Who to give it to" value={toCharacterId} onChange={(e) => setToCharacterId(e.target.value)} style={{ flex: 1, minWidth: 130 }}>
<option value="">To whom…</option>
{others.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button className="btn" onClick={handleGive} disabled={!what || !toCharacterId || !countOk || give.isPending}>
Give
</button>
</div>
)}
{done && <p className="subtitle">✓ {done}</p>}
</div>
);
}
export function CharacterSheetPage() {
const { characterId = '' } = useParams();
const { user } = useAuth();
const { data: character, isLoading, error } = useCharacter(characterId);
const patchCharacter = usePatchCharacter(characterId);
useCampaignSocket(character?.campaignId);
const { data: campaignData } = useCampaign(character?.campaignId);
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
const { data: goldLedger } = useGoldLedger(characterId, Boolean(character) && !character?.restricted);
const { data: locations } = useCampaignLocations(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
const assignWeapon = useAssignWeapon(characterId);
const patchWeapon = usePatchCharacterWeapon(characterId);
const removeWeapon = useRemoveCharacterWeapon(characterId);
const assignItem = useAssignItem(characterId);
const patchItem = usePatchInventoryItem(characterId);
const removeItem = useRemoveInventoryItem(characterId);
const assignSpell = useAssignSpell(characterId);
const patchSpell = usePatchCharacterSpell(characterId);
const removeSpell = useRemoveCharacterSpell(characterId);
const adjustGold = useAdjustGold(characterId);
const createNote = useCreateNote();
const { data: campaigns } = useCampaigns();
const copyCharacter = useCopyCharacter(characterId);
const navigate = useNavigate();
const [hpDelta, setHpDelta] = useState('');
const [pickedItemId, setPickedItemId] = useState('');
const [goldAmount, setGoldAmount] = useState('');
const [goldReason, setGoldReason] = useState('');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
const [copyTargetId, setCopyTargetId] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const weaponsPage = usePagination(character?.weapons, 8);
const spellsPage = usePagination(character?.spells, 8);
const itemsPage = usePagination(character?.inventoryItems, 8);
const notesPage = usePagination(notes, 6);
const ledgerPage = usePagination(goldLedger, 6);
if (isLoading) return <Spinner label="Unfurling the character sheet…" />;
if (error || !character) return <ErrorBanner message="Could not load this character." />;
const isDM = campaignData?.campaign.role === 'DM';
const isOwner = character.userId === user?.id;
const canEdit = isDM || isOwner;
if (character.buildStage !== 'COMPLETE') {
return (
<div className="empty-state card">
<p>{character.name} hasn't finished being built yet.</p>
<Link to={`/characters/${characterId}/build`} className="btn btn-primary">
Continue Build
</Link>
</div>
);
}
const sheet = character.sheet!;
function applyHpDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
if (!amount) return;
const next = character!.currentHp + sign * amount;
patchCharacter.mutate({ currentHp: next });
setHpDelta('');
}
function recomputeMaxHp() {
if (!character || !character.characterClass) return;
const hitDie = CLASS_HIT_DIE[character.characterClass];
const conMod = sheet.abilities.CON.modifier;
const newMax = previewHpMax(character.level, hitDie, conMod);
patchCharacter.mutate({ currentHp: newMax });
}
async function handleAssignItem() {
if (!pickedItemId) return;
try {
await assignItem.mutateAsync({ itemId: pickedItemId });
setPickedItemId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleGold(sign: 1 | -1) {
const amount = Math.floor(Number(goldAmount));
if (!(amount > 0)) return;
setFormError(null);
try {
await adjustGold.mutateAsync({ amount: sign * amount, reason: goldReason.trim() || undefined });
setGoldAmount('');
setGoldReason('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleAddNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
await createNote.mutateAsync({ characterId, body: noteBody, visibility: 'SHARED' });
setNoteBody('');
}
async function handleAssignWeapon() {
if (!pickedWeaponId) return;
try {
await assignWeapon.mutateAsync({ weaponId: pickedWeaponId });
setPickedWeaponId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleAssignSpell() {
if (!pickedSpellId) return;
try {
await assignSpell.mutateAsync({ spellId: pickedSpellId });
setPickedSpellId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleCopy() {
if (!copyTargetId) return;
try {
const copy = await copyCharacter.mutateAsync(copyTargetId);
setCopyTargetId('');
navigate(`/campaigns/${copy.campaignId}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId && !c.archivedAt) ?? [];
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
return (
<div>
<div className="page-header">
<div>
<h1>
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
</h1>
<p className="subtitle">
{character.species && SPECIES_LABELS[character.species]} {character.characterClass && CLASS_LABELS[character.characterClass]} · Level{' '}
{character.level}
{character.motivation && ` · "${character.motivation}"`}
{character.currentLocation && ` · 📍 ${character.currentLocation.name}`}
</p>
</div>
<Link to={`/campaigns/${character.campaignId}`} className="btn btn-ghost">
← Back to Campaign
</Link>
</div>
{formError && <ErrorBanner message={formError} />}
<div className="grid grid-cols-3">
<div className="stat-block">
<span className="stat-label">
HP <InfoTip term="HP" />
</span>
<span className="stat-value">
{character.currentHp}/{sheet.hp.max}
</span>
{character.tempHp > 0 && <span className="stat-sub">+{character.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">
AC <InfoTip term="AC" />
</span>
<span className="stat-value">{sheet.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">
Initiative <InfoTip term="INITIATIVE" />
</span>
<span className="stat-value">{fmtMod(sheet.initiative)}</span>
</div>
<div className="stat-block">
<span className="stat-label">
Speed <InfoTip term="SPEED" />
</span>
<span className="stat-value">{sheet.speed}</span>
</div>
<div className="stat-block">
<span className="stat-label">
Prof. Bonus <InfoTip term="PROFICIENCY_BONUS" />
</span>
<span className="stat-value">{fmtMod(sheet.proficiencyBonus)}</span>
</div>
<div className="stat-block">
<span className="stat-label">
Passive Perception <InfoTip term="PASSIVE_PERCEPTION" />
</span>
<span className="stat-value">{sheet.passivePerception}</span>
</div>
</div>
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<NumberInput
min={1}
max={9999}
placeholder="Amount"
aria-label="HP change"
value={hpDelta}
onChange={(e) => setHpDelta(e.target.value)}
style={{ width: 100 }}
/>
<button className="btn btn-danger" onClick={() => applyHpDelta(-1)}>
− Damage
</button>
<button className="btn" onClick={() => applyHpDelta(1)}>
+ Heal
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP <InfoTip term="TEMP_HP" />
<NumberInput
key={`temp:${character.tempHp}`}
min={0}
max={9999}
keepValue
defaultValue={character.tempHp}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
/>
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<NumberInput
key={`armor:${character.armorBonus}`}
min={0}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
/>
</label>
</div>
)}
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<label className="flex-row gap-sm">
Level
<NumberInput
key={`level:${character.level}`}
min={1}
max={20}
keepValue
defaultValue={character.level}
style={{ width: 60 }}
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
/>
</label>
<button className="btn btn-ghost" onClick={recomputeMaxHp} title="Recompute max HP from the current level and Constitution">
Recompute Max HP <InfoTip term="HIT_DICE" />
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Location
<select
value={character.currentLocationId || ''}
onChange={(e) => patchCharacter.mutate({ currentLocationId: e.target.value || null })}
>
<option value="">— none —</option>
{locations?.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</option>
))}
</select>
</label>
</div>
)}
{!isDM && character.currentLocation && (
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)}
<div className="card mt-md">
<div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3">
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
<div className="stat-block" key={ability}>
<span className="stat-label">
{ABILITY_LABELS[ability]} <InfoTip term="ABILITY_MODIFIER" />
</span>
<span className="stat-value">{sheet.abilities[ability].final}</span>
<span className="stat-sub">{fmtMod(sheet.abilities[ability].modifier)}</span>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 mt-md">
<div className="card">
<div className="card-title">
Saving Throws <InfoTip term="SAVING_THROW" />
</div>
{(Object.keys(sheet.saves) as Ability[]).map((ability) => (
<div key={ability} className="flex-between" style={{ padding: '0.3rem 0' }}>
<span>
{ABILITY_LABELS[ability]} {sheet.saves[ability].proficient && <span className="badge">prof</span>}
</span>
<span className="mono">{fmtMod(sheet.saves[ability].bonus)}</span>
</div>
))}
</div>
<div className="card">
<div className="card-title">
Skills <InfoTip term="SKILL_BONUS" />
</div>
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
{Object.entries(sheet.skills).map(([skill, detail]) => (
<div key={skill} className="flex-between" style={{ padding: '0.25rem 0' }}>
<span>
{SKILL_LABELS[skill]}{' '}
{detail.expertise ? (
<span className="badge badge-dm">expertise</span>
) : detail.proficient ? (
<span className="badge">prof</span>
) : null}
</span>
<span className="mono">{fmtMod(detail.bonus)}</span>
</div>
))}
</div>
</div>
</div>
{sheet.spellcasting && (
<div className="card mt-md">
<div className="card-title">
Spellcasting ({ABILITY_LABELS[sheet.spellcasting.ability]}) <InfoTip term="SPELL_SAVE_DC" />
</div>
<p style={{ margin: 0 }}>
Save DC <strong>{sheet.spellcasting.saveDC}</strong> · Attack Bonus{' '}
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong> <InfoTip term="SPELL_ATTACK_BONUS" />
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">
Weapons <InfoTip term="ATTACK_BONUS" />
</div>
{weaponsPage.pageItems.map((cw) => (
<div key={cw.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cw.weapon.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
Lv {cw.level} · {cw.scaledDamageDice} {cw.weapon.damageType} · x{cw.quantity}
</span>
{!cw.usable && <RequiresLevel level={cw.level} />}
</div>
{canEdit && (
<div className="flex-row gap-sm">
<label className="flex-row gap-sm">
Equipped
<input
type="checkbox"
checked={cw.equipped}
disabled={!cw.usable}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked })}
/>
</label>
{isDM && (
<>
<GearLevelSelect
name={cw.weapon.name}
level={cw.level}
entry={cw.weapon}
onChange={(level) => patchWeapon.mutate({ weaponId: cw.weaponId, level })}
/>
<button className="btn btn-ghost" aria-label={`Take away ${cw.weapon.name}`} onClick={() => removeWeapon.mutate(cw.weaponId)}>
✕
</button>
</>
)}
</div>
)}
</div>
))}
{character.weapons.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No weapons. The DM hands these out.</p>}
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Give a weapon from the campaign pool…</option>
{campaignWeapons
?.filter((w) => !assignedWeaponIds.has(w.id))
.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
<button className="btn" onClick={handleAssignWeapon} disabled={!pickedWeaponId}>
Give
</button>
</div>
)}
</div>
{campaignSpells && campaignSpells.length > 0 && (
<div className="card mt-md">
<div className="card-title">Spells Known</div>
{spellsPage.pageItems.map((cs) => (
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cs.spell.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
{spellLevelLabel(cs.spell.level)}
{cs.spell.damageDice && ` · ${cs.spell.damageDice} damage`}
</span>
{!cs.usable && <RequiresLevel level={cs.spell.level} />}
{cs.spell.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{cs.spell.description}</div>}
</div>
{canEdit && (
<div className="flex-row gap-sm">
<label className="flex-row gap-sm">
Prepared
<input
type="checkbox"
checked={cs.prepared}
disabled={!cs.usable}
onChange={(e) => patchSpell.mutate({ spellId: cs.spellId, prepared: e.target.checked })}
/>
</label>
{isDM && (
<button className="btn btn-ghost" aria-label={`Take away ${cs.spell.name}`} onClick={() => removeSpell.mutate(cs.spellId)}>
✕
</button>
)}
</div>
)}
</div>
))}
{character.spells.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No spells known. The DM grants these.</p>}
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Grant a spell from the campaign list…</option>
{campaignSpells
.filter((s) => !assignedSpellIds.has(s.id))
.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({spellLevelLabel(s.level)})
</option>
))}
</select>
<button className="btn" onClick={handleAssignSpell} disabled={!pickedSpellId}>
Grant
</button>
</div>
)}
</div>
)}
{character.restricted && (
<p className="subtitle mt-md">
This is another player's character. You can see what the party sees across the table; what they carry and how
much gold they have is theirs to share.
</p>
)}
{!character.restricted && (
<>
<div className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => (
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{held.item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
Lv {held.level}
{held.scaledDamageDice && ` · ${held.scaledDamageDice}`} · x{held.quantity}
</span>
{!held.usable && <RequiresLevel level={held.level} />}
{held.item.description && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{held.item.description}</div>
)}
</div>
{canEdit && (
<div className="flex-row gap-sm">
<label className="flex-row gap-sm">
Equipped
<input
type="checkbox"
checked={held.equipped}
disabled={!held.usable}
onChange={(e) => patchItem.mutate({ itemId: held.itemId, equipped: e.target.checked })}
/>
</label>
{isDM && (
<>
<GearLevelSelect
name={held.item.name}
level={held.level}
entry={held.item}
onChange={(level) => patchItem.mutate({ itemId: held.itemId, level })}
/>
<button
className="btn btn-ghost"
aria-label={`One fewer ${held.item.name}`}
disabled={held.quantity <= 1}
onClick={() => patchItem.mutate({ itemId: held.itemId, quantity: held.quantity - 1 })}
>
−
</button>
<button
className="btn btn-ghost"
aria-label={`One more ${held.item.name}`}
onClick={() => patchItem.mutate({ itemId: held.itemId, quantity: held.quantity + 1 })}
>
+
</button>
<button className="btn btn-ghost" aria-label={`Take away ${held.item.name}`} onClick={() => removeItem.mutate(held.itemId)}>
✕
</button>
</>
)}
</div>
)}
</div>
))}
{character.inventoryItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>Nothing carried. The DM hands out items.</p>}
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedItemId} onChange={(e) => setPickedItemId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Give an item from the campaign pool…</option>
{campaignItems?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<button className="btn" onClick={handleAssignItem} disabled={!pickedItemId}>
Give
</button>
</div>
)}
{isDM && campaignItems?.length === 0 && (
<p className="subtitle">The item pool is empty — add items on the campaign page first.</p>
)}
</div>
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
<span className="mono">{character.gold} gp</span>
</div>
{canEdit && (
<div className="flex-row flex-wrap">
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
value={goldAmount}
onChange={(e) => setGoldAmount(e.target.value)}
style={{ width: 100 }}
/>
<input
placeholder="What for? (optional)"
aria-label="Reason"
maxLength={200}
value={goldReason}
onChange={(e) => setGoldReason(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button className="btn" onClick={() => handleGold(1)} disabled={!goldAmount || adjustGold.isPending}>
+ Gain
</button>
<button className="btn btn-danger" onClick={() => handleGold(-1)} disabled={!goldAmount || adjustGold.isPending}>
− Spend
</button>
</div>
)}
<div className="mt-sm">
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
{ledgerPage.pageItems.map((t) => (
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong className="mono">{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`}</strong>
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
{new Date(t.createdAt).toLocaleString()}
</div>
</div>
<span className="mono" style={{ color: 'var(--text-muted)' }}>{t.balanceAfter} gp</span>
</div>
))}
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
</div>
</>
)}
{/* Composed by the character builder from the player's picks. Players
read them; only the DM can rewrite them (new abilities on level-up,
a scar earned in play). */}
{(
[
['specialAbilities', 'Special Abilities', 'One per line, e.g. “Reckless Attack — gained at level 2.”', 6],
['background', 'Background', 'Where this character came from, what drives them…', 5],
['appearance', 'Appearance', 'Height, build, notable features, typical outfit…', 5],
] as const
).map(([field, title, placeholder, rows]) => (
<div className="card mt-md" key={field}>
<div className="card-title">
<span>{title}</span>
{isDM && <span className="badge">DM can edit</span>}
</div>
{isDM ? (
<textarea
key={`${field}:${character[field] ?? ''}`}
className="textarea-block"
rows={rows}
defaultValue={character[field] ?? ''}
placeholder={placeholder}
aria-label={title}
onBlur={(e) => {
const value = e.target.value.trim() || null;
if (value !== (character[field] ?? null)) patchCharacter.mutate({ [field]: value });
}}
/>
) : character[field] ? (
<p className="composed-text">{character[field]}</p>
) : (
<p className="subtitle" style={{ margin: 0 }}>
Nothing here yet. Your DM can fill this in.
</p>
)}
</div>
))}
{canEdit && <GiveCard character={character} onError={(err) => setFormError(extractErrorMessage(err))} />}
{canEdit && otherCampaigns.length > 0 && (
<div className="card mt-md">
<div className="card-title">Copy to Another Campaign</div>
<p className="subtitle" style={{ marginTop: 0 }}>
Makes an independent copy at full HP with the same stats, gear and spells. This character stays here unchanged.
</p>
<div className="flex-row flex-wrap">
<select value={copyTargetId} onChange={(e) => setCopyTargetId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Choose a campaign…</option>
{otherCampaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button className="btn" onClick={handleCopy} disabled={!copyTargetId || copyCharacter.isPending}>
{copyCharacter.isPending ? 'Copying…' : 'Copy Character'}
</button>
</div>
</div>
)}
{isOwner && (
<div className="card mt-md">
<div className="card-title">Private Notes</div>
<p className="subtitle" style={{ marginTop: 0 }}>Only you can see this — not even the DM.</p>
<textarea
rows={4}
defaultValue={character.privateNotes || ''}
placeholder="Secrets, plans, things your character wouldn't say out loud…"
onBlur={(e) => patchCharacter.mutate({ privateNotes: e.target.value })}
/>
</div>
)}
<div className="card mt-md">
<div className="card-title">Notes</div>
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
<textarea
placeholder="Add a note…"
rows={2}
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn">
Add
</button>
</form>
<div className="mt-sm">
{notesPage.pageItems.map((n) => (
<div key={n.id} style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<p style={{ margin: 0 }}>{n.body}</p>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
— {n.author?.displayName} · {new Date(n.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
</div>
<QuickStatsFab characterId={characterId} />
</div>
);
}