Overhaul bottom bar: batch add, custom fields, stat block viewer

Unify the action bar into a single search input with inline bestiary
dropdown. Clicking a dropdown entry queues it with +/- count controls
and a confirm button; Enter or confirm adds N copies to combat.

When no bestiary match exists, optional Init/AC/MaxHP fields appear
for custom creatures. The eye icon opens a separate search dropdown
to preview stat blocks without leaving the add flow.

Fix batch-add bug where only the last creature got a creatureId by
using store.save() instead of setEncounter() in addFromBestiary.
Prevent dropdown buttons from stealing input focus so Enter confirms
the queued batch.

Remove the now-redundant BestiarySearch component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-11 15:27:06 +01:00
parent 460c65bf49
commit b6e052f198
13 changed files with 931 additions and 213 deletions

View File

@@ -65,6 +65,33 @@ function deriveNextId(encounter: Encounter): number {
return max;
}
interface CombatantOpts {
initiative?: number;
ac?: number;
maxHp?: number;
}
function applyCombatantOpts(
makeStore: () => EncounterStore,
id: ReturnType<typeof combatantId>,
opts: CombatantOpts,
): DomainEvent[] {
const events: DomainEvent[] = [];
if (opts.maxHp !== undefined) {
const r = setHpUseCase(makeStore(), id, opts.maxHp);
if (!isDomainError(r)) events.push(...r);
}
if (opts.ac !== undefined) {
const r = setAcUseCase(makeStore(), id, opts.ac);
if (!isDomainError(r)) events.push(...r);
}
if (opts.initiative !== undefined) {
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
if (!isDomainError(r)) events.push(...r);
}
return events;
}
export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]);
@@ -108,7 +135,7 @@ export function useEncounter() {
const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback(
(name: string) => {
(name: string, opts?: CombatantOpts) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
@@ -116,6 +143,13 @@ export function useEncounter() {
return;
}
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
@@ -279,15 +313,14 @@ export function useEncounter() {
.replace(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
const currentEncounter = store.get();
const updated = {
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: cId } : c,
),
};
setEncounter(updated);
});
setEvents((prev) => [...prev, ...addResult]);
},