dd694477b2
Crash fix: - restoreBackup (file + cloud pull) now validates each row through its Zod schema, backfilling new fields — an older backup no longer lands characters missing feats/concentration/etc and crashing the sheet. + test. pf2e parity (closing feature gaps vs 5e): - +9 missing classes (Animist, Exemplar, Gunslinger, Inventor, Kineticist, Magus, Psychic, Summoner, Thaumaturge) with data + class tips. - the wizard now enriches pf2e classes with their caster ability, so pf2e spellcasters get the Spells step + "Caster" tag like 5e. - editable Perception rank and spellcasting proficiency on the pf2e sheet (fixes wrong initiative / spell DC at higher levels); rank-10 spell slots. - pf2e monster resistances/weaknesses/immunities now apply in combat (flat amounts: resistance subtracts, weakness adds, 'all' matches any type). + tests. Glyph purge (finishing the emoji→Lucide migration): replaced ~40 leftover text-glyphs (✕ − + ✦ 🤫 ⬆ ⬇ ☑ ☐ ▶ ◀ ‹ › ★ ↻ ▸ ↑ ●) with Lucide icons across Modal (every dialog), the sheet sections, dice, settings, player panels, world pages, map editor, roll tray, and session UI. Gate: 293 unit + e2e green; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
6.5 KiB
TypeScript
119 lines
6.5 KiB
TypeScript
import { useState } from 'react';
|
|
import type { Character, Combatant, Encounter, MapToken } from '@/lib/schemas';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Select } from '@/components/ui/Input';
|
|
import { KIND_COLOR, baseSpec, unplacedPcs, type TokenSpec } from './tokens';
|
|
|
|
export type { TokenSpec };
|
|
|
|
/** Left sidebar for one-click placement of party / encounter / blank tokens. */
|
|
export function TokenPalette({ characters, encounters, existingTokens, onPlace, onPlaceMany, onClose }: {
|
|
characters: Character[];
|
|
encounters: Encounter[];
|
|
existingTokens: MapToken[];
|
|
onPlace: (spec: TokenSpec) => void;
|
|
onPlaceMany: (specs: TokenSpec[]) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const pcs = characters.filter((c) => c.kind === 'pc');
|
|
// Any encounter that has combatants — planned OR active — can supply tokens.
|
|
const withCombatants = encounters.filter((e) => e.combatants.length > 0);
|
|
const [encId, setEncId] = useState('');
|
|
const selectedEnc = withCombatants.find((e) => e.id === encId)
|
|
?? encounters.find((e) => e.status === 'active' && e.combatants.length > 0)
|
|
?? withCombatants[0] ?? null;
|
|
|
|
const hasPc = (id: string) => existingTokens.some((t) => t.characterId === id);
|
|
const hasCombatant = (cb: Combatant) => existingTokens.some((t) => t.combatantId === cb.id || (!t.combatantId && !t.characterId && t.label === cb.name));
|
|
|
|
const placePc = (c: Character) => onPlace(baseSpec({ label: c.name, color: KIND_COLOR.pc, kind: 'pc', characterId: c.id, hp: c.hp }));
|
|
const addAllPcs = () => onPlaceMany(
|
|
unplacedPcs(pcs, existingTokens).map((c, i) => baseSpec({ label: c.name, color: KIND_COLOR.pc, kind: 'pc', characterId: c.id, hp: c.hp, col: i, row: 0 })),
|
|
);
|
|
const placeCombatant = (cb: Combatant) => onPlace(baseSpec({
|
|
label: cb.name, color: KIND_COLOR[cb.kind], kind: cb.kind, hp: cb.hp, combatantId: cb.id,
|
|
...(cb.characterId ? { characterId: cb.characterId } : {}),
|
|
}));
|
|
|
|
return (
|
|
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
|
|
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">Hide</button>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
|
<section>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-xs font-medium text-ink">Party</span>
|
|
<Button size="sm" variant="ghost" disabled={pcs.length === 0 || pcs.every((c) => hasPc(c.id))} onClick={addAllPcs}>+ All</Button>
|
|
</div>
|
|
{pcs.length === 0 ? (
|
|
<p className="text-xs text-muted">No player characters in this campaign.</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{pcs.map((c) => (
|
|
<li key={c.id}>
|
|
<button onClick={() => placePc(c)} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-accent">
|
|
{c.portrait
|
|
? <img src={c.portrait} alt="" className="h-5 w-5 shrink-0 rounded-full object-cover" />
|
|
: <span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR.pc }} />}
|
|
<span className="flex-1 truncate text-ink">{c.name}</span>
|
|
<span className="text-[10px] uppercase text-muted">{hasPc(c.id) ? 'placed' : `L${c.level}`}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{selectedEnc && (
|
|
<section>
|
|
<div className="mb-1 flex items-center gap-1">
|
|
<span className="text-xs font-medium text-ink">Encounter</span>
|
|
<Button size="sm" variant="ghost" className="ml-auto h-6 px-1.5"
|
|
disabled={selectedEnc.combatants.every((cb) => hasCombatant(cb))}
|
|
onClick={() => onPlaceMany(selectedEnc.combatants.filter((cb) => !hasCombatant(cb)).map((cb, i) => baseSpec({
|
|
label: cb.name, color: KIND_COLOR[cb.kind], kind: cb.kind, hp: cb.hp, combatantId: cb.id, col: i, row: 1,
|
|
...(cb.characterId ? { characterId: cb.characterId } : {}),
|
|
})))}>+ All</Button>
|
|
</div>
|
|
{withCombatants.length > 1 ? (
|
|
<Select className="mb-1 w-full px-2 py-1 pr-7 text-xs leading-tight" value={selectedEnc.id} onChange={(e) => setEncId(e.target.value)} aria-label="Encounter">
|
|
{withCombatants.map((e) => (
|
|
<option key={e.id} value={e.id}>{e.status === 'active' ? '● ' : ''}{e.name} ({e.combatants.length})</option>
|
|
))}
|
|
</Select>
|
|
) : (
|
|
<div className="mb-1 truncate text-[11px] text-muted" title={selectedEnc.name}>{selectedEnc.status === 'active' ? '● ' : ''}{selectedEnc.name}</div>
|
|
)}
|
|
<ul className="space-y-1">
|
|
{selectedEnc.combatants.map((cb) => {
|
|
const placed = hasCombatant(cb);
|
|
return (
|
|
<li key={cb.id}>
|
|
<button onClick={() => placeCombatant(cb)} disabled={placed} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left enabled:hover:border-accent disabled:opacity-50">
|
|
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR[cb.kind] }} />
|
|
<span className="flex-1 truncate text-ink">{cb.name}</span>
|
|
<span className="text-[10px] uppercase text-muted">{placed ? 'placed' : cb.kind}</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<section>
|
|
<div className="mb-1 text-xs font-medium text-ink">Add blank</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: 'NPC', color: KIND_COLOR.npc, kind: 'npc' }))}>NPC</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: 'Foe', color: KIND_COLOR.monster, kind: 'monster' }))}>Monster</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: '', color: KIND_COLOR.object, kind: 'object' }))}>Object</Button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|