- 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>
929 lines
37 KiB
TypeScript
929 lines
37 KiB
TypeScript
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>
|
||
);
|
||
}
|