Files
ttrpg_manager/src/features/world/map/TokenPalette.tsx
T
NilsBriggen 895807d6c9 Polish sprint 2/2: layouts, UX consistency, pf2e conditions
Layout:
- CombatantRow: action controls (damage/AC/move/remove) grouped into one
  right-aligned wrapper so they wrap together instead of scattering on narrow
  widths.
- top bar: the campaign switcher now shrinks/truncates instead of overflowing
  on tablet widths.
- character sheet header: stat coins go full-width grid on mobile; the name
  truncates and scales down on small screens.

UX consistency:
- player Cast and resource Spend/Regain no longer fail silently — Cast shows the
  reason, resource −/+ disable at their bounds.
- the AI encounter builder now catches load failures and reports them; level-up
  advisor shows the human error message, not the error-kind enum.
- section headers standardized on the .smallcaps design token across 16 files
  (replacing an ad-hoc uppercase class).
- player HP bar uses the shared Meter primitive.

pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued.
FeatCard: dropped a dead text-xl wrapper left from the emoji purge.

Left as documented (functional, off-theme, refactor-risky): the native
confirm/prompt in Settings cloud-conflict and the session-password flow.

Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:38:24 +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="smallcaps">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>
);
}