import { useState, type FormEvent, type ReactNode } from 'react'; import { useAddItemFromLibrary, useAddMonsterFromLibrary, useAddSpellFromLibrary, useAddWeaponFromLibrary, useCampaignLibrary, useCreateItem, useCreateMonster, useCreateNpc, useCreateSpell, useCreateWeapon, } from '../../api/queries'; import { Modal } from '../Modal'; import { NumberInput } from '../NumberInput'; import { ErrorBanner, Spinner } from '../Spinner'; import { effectsSummary } from '../../lib/gear'; import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format'; import { MAX_GEAR_LEVEL } from '../../lib/previewCalc'; import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries'; import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts'; import { landingFrom } from '../../lib/campaignEntries'; import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types'; interface SectionProps { campaignId: string; entries: T[] | undefined; onOpen: (entry: EntryRef) => void; // The "add by hand" pop-out is wanted from outside: "add another", pressed // in the pop-out of the one just added. formRequested: boolean; onFormClosed: () => void; } // Which of a list's two "add" pop-outs is open: the form, or the library. type Adding = 'form' | 'library' | null; // Which "add" pop-out a list has open, and what went wrong in it. function useAdding(formRequested: boolean, onFormClosed: () => void) { const [adding, setAdding] = useState(null); const [error, setError] = useState(null); return { adding: formRequested ? ('form' as const) : adding, error, setError, open: (next: Adding) => { setError(null); setAdding(next); }, close: () => { setError(null); setAdding(null); onFormClosed(); }, }; } function SectionCard({ title, addLabel, hasLibrary = true, onAdd, intro, children, }: { title: string; addLabel: string; hasLibrary?: boolean; onAdd: (adding: Adding) => void; intro: ReactNode; children: ReactNode; }) { return (
{title}
{hasLibrary && ( )}

{intro}

{children}
); } // The built-in library in a pop-out. It is a large download, so it is only // asked for once one of these is opened. function LibraryModal({ campaignId, title, pick, taken, adding, onAdd, placeholder, renderMeta, renderDetail, error, onClose, }: { campaignId: string; title: string; pick: (library: CampaignLibrary) => T[]; taken: { name: string }[] | undefined; adding: boolean; onAdd: (key: string) => void; placeholder: string; renderMeta: (item: T) => string; renderDetail?: (item: T) => string | null; error: string | null; onClose: () => void; }) { const { data: library, isError } = useCampaignLibrary(campaignId); return ( {error && } {isError && } {!library && !isError && } {library && ( items={pick(library)} taken={taken} adding={adding} onAdd={onAdd} placeholder={placeholder} renderMeta={renderMeta} renderDetail={renderDetail} /> )} ); } const StartingOption = () => ( ); const startingBadge = (entry: { isStartingOption: boolean }) => entry.isStartingOption && Starting; export function WeaponsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps) { const createWeapon = useCreateWeapon(campaignId); const addFromLibrary = useAddWeaponFromLibrary(campaignId); const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed); async function handleSubmit(e: FormEvent) { e.preventDefault(); const fd = new FormData(e.target as HTMLFormElement); setError(null); try { const weapon = await createWeapon.mutateAsync({ name: String(fd.get('name')).trim(), damageDice: String(fd.get('damageDice')).trim(), damageType: String(fd.get('damageType')).trim(), attackAbility: fd.get('attackAbility') as Weapon['attackAbility'], properties: [], rangeNormal: null, rangeLong: null, damageScaling: Number(fd.get('damageScaling')), minLevel: Number(fd.get('minLevel')), isStartingOption: fd.get('isStartingOption') === 'on', effects: [], effectNote: null, }); close(); onOpen({ kind: 'weapon', id: weapon.id, isNew: true }); } catch (err) { setError(extractErrorMessage(err)); } } return ( The weapons you hand out on each character's sheet. Open one to set the level it starts at, how fast its damage grows (levels 1–{MAX_GEAR_LEVEL}), whether a new character may start with it, and what it does while equipped. } > [w.name, w.damageDice, w.damageType, effectsSummary(w.effects, w.effectNote)]} renderMeta={(w) => [`${w.damageDice} ${w.damageType}`, `Lv ${w.minLevel}`, effectsSummary(w.effects, w.effectNote) && `✦ ${effectsSummary(w.effects, w.effectNote)}`].filter(Boolean).join(' · ')} renderBadges={startingBadge} onOpen={(id) => onOpen({ kind: 'weapon', id })} /> {adding === 'library' && ( campaignId={campaignId} title="Weapon library" pick={(library) => library.weapons} taken={entries} adding={addFromLibrary.isPending} onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })} placeholder="Search the weapon library…" renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`} renderDetail={(w) => effectsSummary(w.effects, w.effectNote)} error={error} onClose={close} /> )} {adding === 'form' && ( {error && }
{(id) => } {(id) => } {(id) => } {(id) => ( )} {(id) => } {(id) => }
)}
); } export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps) { const createItem = useCreateItem(campaignId); const addFromLibrary = useAddItemFromLibrary(campaignId); const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed); async function handleSubmit(e: FormEvent) { e.preventDefault(); const fd = new FormData(e.target as HTMLFormElement); setError(null); try { const item = await createItem.mutateAsync({ name: String(fd.get('name')).trim(), description: String(fd.get('description') || '').trim() || null, damageDice: String(fd.get('damageDice') || '').trim() || null, damageScaling: Number(fd.get('damageScaling')), minLevel: Number(fd.get('minLevel')), isStartingOption: fd.get('isStartingOption') === 'on', effects: [], effectNote: null, armorType: null, }); close(); onOpen({ kind: 'item', id: item.id, isNew: true }); } catch (err) { setError(extractErrorMessage(err)); } } return ( Rope, potions, armor, quest items. Armor, shields, rings and the like are items with effects: open the item and set what it does while equipped (chain mail: +6 AC, −10 Speed). } > [i.name, i.description, i.damageDice, effectsSummary(i.effects, i.effectNote)]} renderMeta={(i) => [effectsSummary(i.effects, i.effectNote) && `✦ ${effectsSummary(i.effects, i.effectNote)}`, i.damageDice, i.description].filter(Boolean).join(' · ') || `Lv ${i.minLevel}`} renderBadges={startingBadge} onOpen={(id) => onOpen({ kind: 'item', id })} /> {adding === 'library' && ( campaignId={campaignId} title="Item library" pick={(library) => library.items} taken={entries} adding={addFromLibrary.isPending} onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })} placeholder="Search the item library: armor, shields, worn gear, supplies…" renderMeta={(i) => [i.category, effectsSummary(i.effects, i.effectNote), i.damageDice].filter(Boolean).join(' · ')} renderDetail={(i) => i.description} error={error} onClose={close} /> )} {adding === 'form' && ( {error && }
{(id) => } {(id) => } {(id) => } {(id) => } {(id) => }
)}
); } export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps) { const createSpell = useCreateSpell(campaignId); const addFromLibrary = useAddSpellFromLibrary(campaignId); const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed); async function handleSubmit(e: FormEvent) { e.preventDefault(); const fd = new FormData(e.target as HTMLFormElement); setError(null); try { const spell = await createSpell.mutateAsync({ name: String(fd.get('name')).trim(), level: Number(fd.get('level')), school: String(fd.get('school') || '').trim() || null, castingTime: null, range: null, duration: null, description: String(fd.get('description') || '').trim() || null, damageDice: String(fd.get('damageDice') || '') || null, isStartingOption: fd.get('isStartingOption') === 'on', revives: fd.get('revives') === 'on', ...landingFrom(String(fd.get('landing') || '')), }); close(); onOpen({ kind: 'spell', id: spell.id, isNew: true }); } catch (err) { setError(extractErrorMessage(err)); } } return ( [s.name, s.school, s.description, spellLevelLabel(s.level)]} renderMeta={(s) => [spellLevelLabel(s.level), s.damageDice && `${s.damageDice} damage`, s.description].filter(Boolean).join(' · ')} renderBadges={(s) => ( <> {s.revives && Revives} {startingBadge(s)} )} onOpen={(id) => onOpen({ kind: 'spell', id })} /> {adding === 'library' && ( campaignId={campaignId} title="Spell library" pick={(library) => library.spells} taken={entries} adding={addFromLibrary.isPending} onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })} placeholder="Search the spell library…" renderMeta={(s) => [spellLevelLabel(s.level), spellRequirementLabel(s.level), s.damageDice ? `${s.damageDice} damage` : 'no damage', s.school].filter(Boolean).join(' · ') } renderDetail={(s) => s.description} error={error} onClose={close} /> )} {adding === 'form' && ( {error && }
{(id) => } {(id) => ( )} {(id) => } {(id) => } {(id) => } {(id) => }
)}
); } export function MonstersSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps) { const createMonster = useCreateMonster(campaignId); const addFromLibrary = useAddMonsterFromLibrary(campaignId); const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed); return ( [m.name, m.challengeRating && `CR ${m.challengeRating}`, m.specialTrait, m.notes]} renderMeta={(m) => `CR ${m.challengeRating || '—'} · AC ${m.ac} · HP ${m.maxHp} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''}`} onOpen={(id) => onOpen({ kind: 'monster', id })} /> {adding === 'library' && ( campaignId={campaignId} title="Monster library" pick={(library) => library.monsters} taken={entries} adding={addFromLibrary.isPending} onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })} placeholder="Search the monster library…" renderMeta={(m) => `CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}` } renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null} error={error} onClose={close} /> )} {adding === 'form' && ( {error && } { setError(null); try { const monster = await createMonster.mutateAsync(stats); close(); onOpen({ kind: 'monster', id: monster.id, isNew: true }); } catch (err) { setError(extractErrorMessage(err)); throw err; } }} /> )} ); } export function NpcsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps) { const createNpc = useCreateNpc(campaignId); const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed); async function handleSubmit(e: FormEvent) { e.preventDefault(); const fd = new FormData(e.target as HTMLFormElement); const text = (key: string) => String(fd.get(key) || '').trim() || null; const num = (key: string) => (fd.get(key) ? Number(fd.get(key)) : null); setError(null); try { const npc = await createNpc.mutateAsync({ name: String(fd.get('name')).trim(), role: text('role'), disposition: fd.get('disposition') as NpcDisposition, description: text('description'), dmNotes: text('dmNotes'), ac: num('ac'), maxHp: num('maxHp'), attackBonus: num('attackBonus'), damageDice: text('damageDice'), }); close(); onOpen({ kind: 'npc', id: npc.id, isNew: true }); } catch (err) { setError(extractErrorMessage(err)); } } return ( [n.name, n.role, n.disposition, n.description, n.dmNotes]} renderMeta={(n) => [n.role, n.description].filter(Boolean).join(' · ')} renderBadges={(n) => ( <> {rateLabel(n) && 💱 {rateLabel(n)}} {(n.wares?.length ?? 0) > 0 && 🛒 {n.wares?.length}} {n.disposition} )} onOpen={(id) => onOpen({ kind: 'npc', id })} /> {adding === 'form' && ( {error && }
{(id) => } {(id) => } {(id) => ( )}
{(id) =>