Files
ttrpg_manager/src/features/world/map/TokenPalette.tsx
T
NilsBriggen dd694477b2 Polish sprint 1/2: restore crash fix, pf2e parity, full glyph purge
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>
2026-06-09 11:29:36 +02:00

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>
);
}