Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a867fd1a1 |
@@ -56,7 +56,9 @@ including a character sheet or an encounter) and **Account**.
|
||||
- The DM's lists (NPCs, weapons, items, spells, monsters) are short rows: a
|
||||
name and a few words. **Clicking a row opens a pop-out** with everything
|
||||
about it and everything that can be changed. Adding one, by hand or from the
|
||||
library, is a pop-out too. A pop-out is a window over the page on a wide
|
||||
library, is a pop-out too. One added by hand opens its own pop-out, with an
|
||||
**"+ Add another"** button at the top that goes straight back to an empty
|
||||
form. A pop-out is a window over the page on a wide
|
||||
screen and the whole screen on a phone; ✕, Escape or a click outside it
|
||||
closes it.
|
||||
- Every paged list has Prev and Next, and with three pages or more the page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ everything below and is where it is changed.
|
||||
|
||||
- **Adding:** by hand with "+ Add Item", or with "📚 From Library". Both open
|
||||
in a pop-out; an item added by hand opens its own pop-out straight away, to
|
||||
set what it does. The library offers 32 ready-made items: light, medium and heavy armor, shields,
|
||||
set what it does, and "+ Add another item" at its top goes straight back
|
||||
to an empty form. The library offers 32 ready-made items: light, medium and heavy armor, shields,
|
||||
worn gear such as a Ring of Protection, and common supplies. Each is copied
|
||||
into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed,
|
||||
and a note about Stealth), at starting level 1 and not ticked as a starting
|
||||
|
||||
@@ -60,6 +60,8 @@ search box, shows ten to a page, and clicking a row opens the monster's
|
||||
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
|
||||
scores, special trait and notes. Only name, AC and HP must be typed; speed
|
||||
starts at 30, attack bonus at 0 and each ability score at 10.
|
||||
- Once added by hand, the monster's own pop-out opens. "+ Add another
|
||||
monster" at its top goes straight back to an empty form.
|
||||
- **Adding from the bestiary:** "📚 From Library" opens the bestiary in a
|
||||
pop-out, and "+ Add" copies a complete stat block.
|
||||
Each monster can be added once: one the campaign already has by that name is
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@ have for sale. The list has a search box and shows ten to a page.
|
||||
above: description, DM notes, combat stats, and their trade.
|
||||
- **Adding:** "+ Add NPC" opens a pop-out with every field above except the
|
||||
exchange rate and wares. Only the name is required. Once added, the NPC's
|
||||
own pop-out opens, to set their trade.
|
||||
own pop-out opens, to set their trade; "+ Add another NPC" at its top goes
|
||||
straight back to an empty form.
|
||||
- **Trade:** the exchange rate and the wares are set in the NPC's pop-out,
|
||||
under "Changes money" and "For sale" (section 5).
|
||||
- **Changing:** there is no edit form for the other fields. To change them,
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
|
||||
|
||||
- **Adding:** by hand with "+ Add Spell" (name, level, school, damage, what
|
||||
it does and the two tick boxes), or from the library. Both open in a
|
||||
pop-out.
|
||||
pop-out. A spell added by hand opens its own pop-out, and "+ Add another
|
||||
spell" at its top goes straight back to an empty form.
|
||||
- **Changing:** in the spell's pop-out: damage (the drop-down) and the Revives
|
||||
and starting-option tick boxes, saved as they are changed. Name, level, school and description cannot be changed on screen
|
||||
once created (the API accepts it); remove and re-add instead.
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ it is changed.
|
||||
|
||||
- **Adding:** by hand with "+ Add Weapon", or from the library. Both open in
|
||||
a pop-out. A weapon added by hand opens its own pop-out straight away, to
|
||||
set what it does.
|
||||
set what it does; "+ Add another weapon" at its top goes straight back to
|
||||
an empty form.
|
||||
- **Effects:** under "While equipped" in the weapon's pop-out, saved with
|
||||
"Save Effects". A defending blade can give +1 AC, a heavy maul −5 speed.
|
||||
They count only while the weapon is equipped, and do not change its own
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,10 +6,11 @@ import { ErrorBanner } from '../Spinner';
|
||||
import { EffectsEditor } from '../EffectsEditor';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
|
||||
|
||||
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
|
||||
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
|
||||
|
||||
function Chip({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -33,7 +34,7 @@ function RemoveButton({ name, warning, pending, onRemove }: { name: string; warn
|
||||
}
|
||||
|
||||
// A weapon or an item: the two share levels, growth and effects.
|
||||
function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string; pool: 'weapons' | 'items'; entry: Weapon | Item; onClose: () => void }) {
|
||||
function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactNode; campaignId: string; pool: 'weapons' | 'items'; entry: Weapon | Item; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, pool);
|
||||
const remove = useDeletePoolEntry(campaignId, pool);
|
||||
const [effects, setEffects] = useState<GearEffect[]>(entry.effects ?? []);
|
||||
@@ -45,6 +46,7 @@ function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string;
|
||||
|
||||
return (
|
||||
<Modal title={entry.name} subtitle={weapon ? 'Weapon' : 'Item'} onClose={onClose}>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="stat-chips">
|
||||
{weapon ? (
|
||||
@@ -117,7 +119,7 @@ function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string;
|
||||
);
|
||||
}
|
||||
|
||||
function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell: Spell; onClose: () => void }) {
|
||||
function SpellDetail({ campaignId, spell, added, onClose }: { added: ReactNode; campaignId: string; spell: Spell; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'spells');
|
||||
const remove = useDeletePoolEntry(campaignId, 'spells');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -125,6 +127,7 @@ function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell
|
||||
|
||||
return (
|
||||
<Modal title={spell.name} subtitle={[spellLevelLabel(spell.level), spell.school, spellRequirementLabel(spell.level)].filter(Boolean).join(' · ')} onClose={onClose}>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="stat-chips">
|
||||
{spell.castingTime && <Chip label="Casting time">{spell.castingTime}</Chip>}
|
||||
@@ -152,7 +155,7 @@ function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell
|
||||
);
|
||||
}
|
||||
|
||||
function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; monster: Monster; onClose: () => void }) {
|
||||
function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNode; campaignId: string; monster: Monster; onClose: () => void }) {
|
||||
const update = useUpdateMonster(campaignId);
|
||||
const remove = useDeleteMonster(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -161,6 +164,7 @@ function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; m
|
||||
|
||||
return (
|
||||
<Modal title={editing ? `Editing ${monster.name}` : monster.name} subtitle="Monster" wide onClose={onClose}>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{editing ? (
|
||||
<MonsterForm
|
||||
@@ -213,7 +217,7 @@ function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; m
|
||||
// An NPC: who they are, and what they trade — the rate they change gold and
|
||||
// cash at, and their wares. Players meet both on the encounter page, in a
|
||||
// quest the NPC is part of.
|
||||
function NpcDetail({ campaignId, npc, weapons, items, onClose }: { campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added: ReactNode; campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const remove = useDeletePoolEntry(campaignId, 'npcs');
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
@@ -236,6 +240,7 @@ function NpcDetail({ campaignId, npc, weapons, items, onClose }: { campaignId: s
|
||||
wide
|
||||
onClose={onClose}
|
||||
>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{npc.description ? <p style={{ whiteSpace: 'pre-wrap' }}>{npc.description}</p> : <p style={{ color: 'var(--text-muted)' }}>No description for the players.</p>}
|
||||
{npc.dmNotes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>🔒 {npc.dmNotes}</p>}
|
||||
@@ -310,6 +315,7 @@ export function EntryDetail({
|
||||
spells,
|
||||
monsters,
|
||||
npcs,
|
||||
onAddAnother,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
@@ -319,29 +325,39 @@ export function EntryDetail({
|
||||
spells: Spell[];
|
||||
monsters: Monster[];
|
||||
npcs: Npc[];
|
||||
// Given for something added by hand a moment ago: opens the form it came from again.
|
||||
onAddAnother?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const added = onAddAnother && (
|
||||
<div className="added-bar">
|
||||
<span>✓ Added to the campaign.</span>
|
||||
<button className="btn btn-primary" onClick={onAddAnother}>
|
||||
+ Add another {KIND_NOUNS[entry.kind]}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const find = <T extends { id: string }>(list: T[]) => list.find((e) => e.id === entry.id);
|
||||
switch (entry.kind) {
|
||||
case 'weapon': {
|
||||
const weapon = find(weapons);
|
||||
return weapon ? <GearDetail key={weapon.id} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
|
||||
return weapon ? <GearDetail key={weapon.id} added={added} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'item': {
|
||||
const item = find(items);
|
||||
return item ? <GearDetail key={item.id} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
|
||||
return item ? <GearDetail key={item.id} added={added} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'spell': {
|
||||
const spell = find(spells);
|
||||
return spell ? <SpellDetail key={spell.id} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
|
||||
return spell ? <SpellDetail key={spell.id} added={added} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'monster': {
|
||||
const monster = find(monsters);
|
||||
return monster ? <MonsterDetail key={monster.id} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
|
||||
return monster ? <MonsterDetail key={monster.id} added={added} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'npc': {
|
||||
const npc = find(npcs);
|
||||
return npc ? <NpcDetail key={npc.id} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
|
||||
return npc ? <NpcDetail key={npc.id} added={added} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,35 @@ interface SectionProps<T> {
|
||||
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<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
@@ -117,15 +141,10 @@ const StartingOption = () => (
|
||||
|
||||
const startingBadge = (entry: { isStartingOption: boolean }) => entry.isStartingOption && <span className="badge">Starting</span>;
|
||||
|
||||
export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Weapon>) {
|
||||
export function WeaponsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Weapon>) {
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const addFromLibrary = useAddWeaponFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -146,8 +165,8 @@ export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Wea
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'weapon', id: weapon.id });
|
||||
close();
|
||||
onOpen({ kind: 'weapon', id: weapon.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -186,11 +205,11 @@ export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Wea
|
||||
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`}
|
||||
renderDetail={(w) => effectsSummary(w.effects, w.effectNote)}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={() => setAdding(null)}>
|
||||
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
@@ -220,15 +239,10 @@ export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Wea
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>) {
|
||||
export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Item>) {
|
||||
const createItem = useCreateItem(campaignId);
|
||||
const addFromLibrary = useAddItemFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -245,8 +259,8 @@ export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'item', id: item.id });
|
||||
close();
|
||||
onOpen({ kind: 'item', id: item.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -285,11 +299,11 @@ export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>
|
||||
renderMeta={(i) => [i.category, effectsSummary(i.effects, i.effectNote), i.damageDice].filter(Boolean).join(' · ')}
|
||||
renderDetail={(i) => i.description}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={() => setAdding(null)}>
|
||||
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
@@ -312,15 +326,10 @@ export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spell>) {
|
||||
export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Spell>) {
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
const addFromLibrary = useAddSpellFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -339,8 +348,8 @@ export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spel
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'spell', id: spell.id });
|
||||
close();
|
||||
onOpen({ kind: 'spell', id: spell.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -381,11 +390,11 @@ export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spel
|
||||
}
|
||||
renderDetail={(s) => s.description}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a spell" onClose={() => setAdding(null)}>
|
||||
<Modal title="Add a spell" onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
@@ -422,15 +431,10 @@ export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spel
|
||||
);
|
||||
}
|
||||
|
||||
export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Monster>) {
|
||||
export function MonstersSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Monster>) {
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const addFromLibrary = useAddMonsterFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
@@ -461,11 +465,11 @@ export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Mo
|
||||
}
|
||||
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a monster" wide onClose={() => setAdding(null)}>
|
||||
<Modal title="Add a monster" wide onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<MonsterForm
|
||||
saving={createMonster.isPending}
|
||||
@@ -473,8 +477,8 @@ export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Mo
|
||||
setError(null);
|
||||
try {
|
||||
const monster = await createMonster.mutateAsync(stats);
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'monster', id: monster.id });
|
||||
close();
|
||||
onOpen({ kind: 'monster', id: monster.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
throw err;
|
||||
@@ -487,14 +491,9 @@ export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Mo
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>) {
|
||||
export function NpcsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Npc>) {
|
||||
const createNpc = useCreateNpc(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -514,8 +513,8 @@ export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>)
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'npc', id: npc.id });
|
||||
close();
|
||||
onOpen({ kind: 'npc', id: npc.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -545,7 +544,7 @@ export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>)
|
||||
onOpen={(id) => onOpen({ kind: 'npc', id })}
|
||||
/>
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an NPC" subtitle="Only the name is needed. What they sell is set once they are added." onClose={() => setAdding(null)}>
|
||||
<Modal title="Add an NPC" subtitle="Only the name is needed. What they sell is set once they are added." onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
|
||||
@@ -1954,3 +1954,18 @@ table.data-table tbody tr.is-mine:hover {
|
||||
padding: 0.9rem 0.85rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* At the top of the pop-out of something added a moment ago. */
|
||||
.added-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--success);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(95, 138, 79, 0.14);
|
||||
color: var(--success-strong);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
|
||||
export interface EntryRef {
|
||||
kind: EntryKind;
|
||||
id: string;
|
||||
// It was added by hand a moment ago: its pop-out offers to add another.
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
|
||||
|
||||
@@ -155,6 +155,8 @@ export function CampaignHomePage() {
|
||||
const [query, setQuery] = useState('');
|
||||
// The weapon, item, spell, monster or NPC whose pop-out is open.
|
||||
const [opened, setOpened] = useState<EntryRef | null>(null);
|
||||
// The list whose "add by hand" pop-out was asked for from the pop-out of the one just added.
|
||||
const [addingKind, setAddingKind] = useState<EntryKind | null>(null);
|
||||
const [newCharName, setNewCharName] = useState('');
|
||||
const [newEncounterName, setNewEncounterName] = useState('');
|
||||
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
|
||||
@@ -652,11 +654,11 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDM && show('npcs') && <NpcsSection campaignId={campaignId} entries={npcs} onOpen={setOpened} />}
|
||||
{isDM && show('weapons') && <WeaponsSection campaignId={campaignId} entries={weapons} onOpen={setOpened} />}
|
||||
{isDM && show('items') && <ItemsSection campaignId={campaignId} entries={items} onOpen={setOpened} />}
|
||||
{isDM && show('spells') && <SpellsSection campaignId={campaignId} entries={spells} onOpen={setOpened} />}
|
||||
{isDM && show('monsters') && <MonstersSection campaignId={campaignId} entries={monsters} onOpen={setOpened} />}
|
||||
{isDM && show('npcs') && <NpcsSection campaignId={campaignId} entries={npcs} onOpen={setOpened} formRequested={addingKind === 'npc'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('weapons') && <WeaponsSection campaignId={campaignId} entries={weapons} onOpen={setOpened} formRequested={addingKind === 'weapon'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('items') && <ItemsSection campaignId={campaignId} entries={items} onOpen={setOpened} formRequested={addingKind === 'item'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('spells') && <SpellsSection campaignId={campaignId} entries={spells} onOpen={setOpened} formRequested={addingKind === 'spell'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('monsters') && <MonstersSection campaignId={campaignId} entries={monsters} onOpen={setOpened} formRequested={addingKind === 'monster'} onFormClosed={() => setAddingKind(null)} />}
|
||||
|
||||
{show('notes') && (
|
||||
<div className="card">
|
||||
@@ -883,6 +885,14 @@ export function CampaignHomePage() {
|
||||
spells={spells ?? []}
|
||||
monsters={monsters ?? []}
|
||||
npcs={npcs ?? []}
|
||||
onAddAnother={
|
||||
opened.isNew
|
||||
? () => {
|
||||
setAddingKind(opened.kind);
|
||||
setOpened(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setOpened(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user