895807d6c9
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>
87 lines
4.1 KiB
TypeScript
87 lines
4.1 KiB
TypeScript
|
|
import { useState } from 'react';
|
|
import { X, Minus, Plus } from 'lucide-react';
|
|
import { newId } from '@/lib/ids';
|
|
import { getSystem, applyRest } from '@/lib/rules';
|
|
import { spendResource, regainResource } from '@/lib/mechanics';
|
|
import type { CharacterResource } from '@/lib/schemas';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Input, Select } from '@/components/ui/Input';
|
|
import { NumberField } from '@/components/ui/NumberField';
|
|
import { SheetSection, type SectionProps } from './common';
|
|
|
|
const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
|
|
short: 'Short rest',
|
|
long: 'Long rest',
|
|
daily: 'Daily',
|
|
none: 'Manual',
|
|
};
|
|
|
|
export function ResourcesSection({ c, update }: SectionProps) {
|
|
const [name, setName] = useState('');
|
|
const sys = getSystem(c.system);
|
|
|
|
const addResource = () => {
|
|
if (name.trim() === '') return;
|
|
const r: CharacterResource = { id: newId(), name: name.trim(), current: 1, max: 1, recovery: 'long' };
|
|
update({ resources: [...c.resources, r] });
|
|
setName('');
|
|
};
|
|
const patch = (id: string, p: Partial<CharacterResource>) =>
|
|
update({ resources: c.resources.map((r) => (r.id === id ? { ...r, ...p } : r)) });
|
|
const remove = (id: string) => update({ resources: c.resources.filter((r) => r.id !== id) });
|
|
|
|
const rest = (optId: string) => {
|
|
const opt = sys.restOptions.find((o) => o.id === optId);
|
|
if (opt) update(applyRest(c, opt));
|
|
};
|
|
|
|
return (
|
|
<SheetSection
|
|
title="Resources & Rest"
|
|
actions={
|
|
<div className="flex gap-2">
|
|
{sys.restOptions.map((o) => (
|
|
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={`Restore: ${o.recovers.join(', ')}`}>
|
|
{o.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
}
|
|
>
|
|
<div className="mb-3 flex items-end gap-2">
|
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
|
New resource
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addResource()} placeholder="Ki Points, Rage, Focus…" />
|
|
</label>
|
|
<Button variant="primary" onClick={addResource}>Add</Button>
|
|
</div>
|
|
|
|
{c.resources.length === 0 ? (
|
|
<p className="text-sm text-muted">No tracked resources. Add rage, ki, sorcery points, focus points, etc.</p>
|
|
) : (
|
|
<ul className="grid gap-2 sm:grid-cols-2">
|
|
{c.resources.map((r) => (
|
|
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
|
|
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
|
|
<Button size="icon" variant="ghost" disabled={r.current <= 0} onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend"><Minus size={14} aria-hidden /></Button>
|
|
<span className="w-12 text-center text-sm">
|
|
<span className="font-medium text-ink">{r.current}</span>
|
|
<span className="text-muted">/{r.max}</span>
|
|
</span>
|
|
<Button size="icon" variant="ghost" disabled={r.current >= r.max} onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain"><Plus size={14} aria-hidden /></Button>
|
|
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
|
|
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
|
|
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
|
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
|
|
))}
|
|
</Select>
|
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><X size={14} aria-hidden /></Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</SheetSection>
|
|
);
|
|
}
|