Compare commits

..
2 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 3a867fd1a1 V1.13.1: add another, straight from the one just added
- A weapon, item, spell, monster or NPC added by hand opens its own
  pop-out, as before. That pop-out now has "+ Add another weapon" (or
  item, spell, monster, NPC) at its top, which goes straight back to an
  empty form: no closing it and pressing "+ Add" again.
- All packages at version 1.13.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:38:53 +02:00
joshuaandClaude Opus 5.5 37c561c5d3 V1.13.0: a campaign page in parts, pop-outs, and a search
- The campaign page shows one part at a time, picked from a row of
  buttons under its name: Overview (characters and encounters), NPCs,
  Locations, Weapons, Items, Spells, Monsters, Notes and Settings for
  the DM; Overview, Places and Notes for a player. Each button counts
  what is in its part. On a phone the row scrolls sideways. The part
  showing is in the address (?view=weapons), so the back button steps
  through the parts.
- NPCs, weapons, items, spells and monsters are lists of short rows.
  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 window on a wide screen, the whole
  screen on a phone.
- "Search this campaign" looks through everything added to the
  campaign at once: characters, encounters, locations and notes, and
  for the DM also NPCs, weapons, items, spells and monsters. Each of
  the DM's lists has a search box of its own as well.
- With three pages or more, the page number of a paged list is a menu
  to jump straight to any page.
- The invite code, the party and archive or delete are under Settings.
- A weapon's properties and range, and a spell's casting time, range
  and duration, are shown in its pop-out.
- All packages at version 1.13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:26:11 +02:00
24 changed files with 2351 additions and 1434 deletions
+25 -3
View File
@@ -40,9 +40,31 @@ including a character sheet or an encounter) and **Account**.
- The **🧙 character button** is on every page (floating at the bottom right
on a wide screen). It opens a panel with your character's numbers and
everything they hold; the DM can open it for any character.
- The campaign page and the character sheet are long, so each starts with a
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
jump straight to that part of the page.
- The **campaign page shows one part at a time**, picked from a row of
buttons under its name: Overview (characters and encounters), NPCs,
Locations, Weapons, Items, Spells, Monsters, Notes and Settings for the DM;
Overview, Places and Notes for a player. Each button shows how many things
are in that part. On a phone the row is one line that scrolls sideways. The
part showing is in the address (`?view=weapons`), so it can be bookmarked
and the back button steps through the parts.
- **Search this campaign**, the box above that row, looks through everything
that has been added to the campaign at once: characters, encounters,
locations and notes, and for the DM also NPCs, weapons, items, spells and
monsters. It matches names and descriptions, every word typed, in any
order. A result opens that thing. Each of the DM's lists also has a search
box of its own.
- 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. 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
number is a menu to **jump straight to any page**.
- The character sheet is long, so it starts with a row of **section links**
that jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
overview) is shown as a stack of cards, one per row, with each figure
labelled, and rows of controls wrap rather than run off the side.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.12.3",
"version": "1.13.1",
"private": true,
"type": "commonjs",
"main": "src/index.js",
+4 -2
View File
@@ -421,7 +421,9 @@ calculations but is only ever set if the DM supplies it during step 5.
## 10. Gear pools, starting options and gear levels
Each campaign has three **pools**, managed by the DM on the campaign page:
Each campaign has three **pools**, managed by the DM on the campaign page,
each in a part of its own ("Weapons", "Items", "Spells") where every entry
opens in a pop-out:
| Pool | What an entry holds | How entries are added |
|---|---|---|
@@ -666,7 +668,7 @@ character in the same campaign.
| Screen | What it shows |
|---|---|
| Campaign page | Character list: species, class, level, HP, AC, location. A player's own characters are under "Your Characters" and nowhere else; "Rest of the Party" is everyone else's. |
| Campaign page | On its "Overview". Character list: species, class, level, HP, AC, location. A player's own characters are under "Your Characters" and nowhere else; "Rest of the Party" is everyone else's. |
| Party overview | One table of every character with an HP bar, AC, gold and location. A player's own characters are highlighted and marked "★ Yours". |
| Character sheet | Everything in this document. |
| Character builder | A character that is still being built (section 2). |
+7 -5
View File
@@ -77,18 +77,20 @@ characters are at that location: beginning it, changing its location, or
adding a character to it moves them there. Each place a character is moved to
(this way or by the DM on the sheet) is remembered, and a player can see only
the locations one of their own characters has been to — on the campaign page
under "Places You've Been", and on the encounter itself. Archiving or deleting
under "Places", and on the encounter itself. Archiving or deleting
the encounter does not take a visited place away again.
### Order, archive and delete
**Order.** The campaign page lists encounters in the **campaign order**, which
**Order.** The campaign page lists encounters on its "Overview", under the
characters, in the **campaign order**, which
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
The list shows **ten encounters a page**, with Prev and Next beneath it, and
The list shows **ten encounters a page**, with Prev and Next beneath it (and,
with three pages or more, a menu to jump to any page), and
the DM's archived list is paged the same way. The order runs on across pages:
moving the last encounter on a page down carries it to the top of the next.
@@ -410,8 +412,8 @@ when there is something to trade, and always for the DM in a quest.
holds at the level it is sold at, and uses up stock. A character below that
level holds it but cannot use it yet.
- Every trade is written to the character's money log.
- **An NPC's rate and wares are set on the campaign page**, under "Trade" on
the NPC's row, and follow the NPC into every quest they are in. **An
- **An NPC's rate and wares are set on the campaign page**, in the NPC's
pop-out, and follow the NPC into every quest they are in. **An
encounter's own wares are set here**, in the DM's part of the card, and
exist only in this encounter: a market stall, a vending machine, a chest
with a price tag.
+18 -11
View File
@@ -36,7 +36,11 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
## 2. The item pool
The pool is the "Items Pool" card on the campaign page (DM only).
The pool is the "Items" part of the campaign page (DM only). It lists each
item as a short row: its name, then what it does, its dice and its
description, as far as they fit on a line. The list has a search box, shows
ten to a page, and clicking a row opens the item's **pop-out**, which shows
everything below and is where it is changed.
| Field | What it is |
|---|---|
@@ -48,21 +52,24 @@ The pool is the "Items Pool" card on the campaign page (DM only).
| Starting option | Whether a brand-new character may pick it in the builder (section 6) |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note (section 5) |
- **Adding:** by hand with the "+ Add Item" form, or with "📚 From Library",
which offers 32 ready-made items: light, medium and heavy armor, shields,
- **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, 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
option. The copy is yours to change. The full list is in section 10. Each
can be added once: an item the pool already has by that name is left out of
the library list, and comes back if the pool's item is renamed or deleted.
- **Effects:** the "Effects" button on an item's row. The row shows what the
item does under its name.
- **Effects:** under "While equipped" in the item's pop-out, saved with "Save
Effects". The item's row shows what it does under its name.
- **Changing:** starting level, growth (when the item has dice) and the
starting-option tick box can be changed in the table. Name, description and
dice cannot be changed on screen once created (the API accepts it); remove
and re-add instead.
- **Removing:** the ✕ button. This takes the item out of **every inventory**
starting-option tick box are changed in the pop-out, and saved as they are
changed. Name, description and dice cannot be changed on screen once
created (the API accepts it); remove and re-add instead.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. This takes the item out of **every inventory**
and off every encounter's reward list. There is no undo.
## 3. A character's inventory
@@ -188,8 +195,8 @@ plus a **note** for what is not a number ("Disadvantage on Stealth",
card lists every item in force with what it is doing, and its note.
- **Max HP can move.** If taking something off lowers a character's maximum
HP below their current HP, current HP comes down to the new maximum.
- **Only the DM sets effects**, with the "Effects" button on the item's row in
the pool. Changing them changes the sheet of everyone wearing the item,
- **Only the DM sets effects**, under "While equipped" in the item's pop-out
on the campaign page. Changing them changes the sheet of everyone wearing the item,
straight away.
- The DM also has an **Extra AC** number on each sheet, added on top of gear,
for anything an item does not cover. It can be negative.
+16 -7
View File
@@ -50,22 +50,31 @@ from its scores for display only.
## 3. Managing monsters
The list is the "Monsters" card on the campaign page (DM only).
The list is the "Monsters" part of the campaign page (DM only). Each monster
is a short row: its name, challenge rating, AC, HP and attack. The list has a
search box, shows ten to a page, and clicking a row opens the monster's
**pop-out** with its whole stat block.
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
- **Adding by hand:** "+ Add Monster" opens a pop-out with the whole stat
block: name,
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.
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
- 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
left out of the list, and comes back if the campaign's monster is renamed or
deleted.
- **Changing:** "Edit" on a monster's row opens the same form filled in.
- **Changing:** "Edit stat block" in a monster's pop-out opens the same form
filled in.
Saving changes the template, and copies already in encounters show the new
stat block straight away; their current HP is left alone.
- The list shows each monster's full stat block: the numbers in columns, and
its ability scores, trait and notes under its name.
- **Removing:** the ✕ button, after a confirmation. Copies
- The pop-out shows the full stat block: the numbers, the six ability scores,
the trait and the notes.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. Copies
already in encounters stay there with their name and HP, but lose their
stat block.
+16 -9
View File
@@ -52,18 +52,24 @@ A new NPC is Neutral unless you choose otherwise.
## 3. Managing NPCs
The NPC list is the "NPCs" card on the campaign page (DM only).
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
short row: name, role, the start of their description, and badges for their
attitude, a 💱 with their exchange rate and a 🛒 with how many things they
have for sale. The list has a search box and shows ten to a page.
- **Adding:** "+ Add NPC" opens a form with every field above except the
exchange rate and wares. Only the name is required.
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
badge with how many things are for sale.
- **The pop-out:** clicking a row opens the NPC's pop-out, with everything
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; "+ 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,
remove the NPC and add it again (the API accepts changes, but no screen
offers them). Removing an NPC removes their wares too.
- **Removing:** the ✕ button, after a confirmation. This also **removes the
NPC from every encounter** it was in.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. This also **removes the NPC from every encounter** it was in.
## 4. NPCs in encounters
@@ -113,7 +119,8 @@ another.
- Both sides go on the character's money log, naming the NPC: "Changed with
Vex for $45" and "Changed with Vex from 6 gold".
- An NPC never runs out of gold or cash to change.
- To set a rate, type both numbers under "Trade" and press "Save rate".
- To set a rate, type both numbers under "Changes money" in the NPC's pop-out
and press "Save rate".
"Stop changing money" clears it. An NPC with no rate does not change money.
### Selling
+11 -6
View File
@@ -36,7 +36,10 @@ This describes the code as it stands. The built-in list is
## 2. The spell list
The list is the "Spell List" card on the campaign page (DM only).
The list is the "Spells" part of the campaign page (DM only). Each spell is a
short row: its name, level, damage and the start of what it does. The list
has a search box, shows ten to a page, and clicking a row opens the spell's
**pop-out**, which shows everything below and is where it is changed.
| Field | What it is |
|---|---|
@@ -47,15 +50,17 @@ The list is the "Spell List" card on the campaign page (DM only).
| Damage | Picked from a list, or "No damage". Fixed; it does not grow |
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
| Casting time, range, duration | Stored for library spells but not shown on any screen |
| Casting time, range, duration | Stored for library spells. Shown to the DM in the spell's pop-out on the campaign page, and nowhere else |
A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
"level 0" or "level 1".
- **Adding:** by hand with the "+ Add Spell" form (name, level, school,
damage, what it does and the two tick boxes), or from the library.
- **Changing:** damage (the drop-down in the table) and the Revives and
starting-option tick boxes. Name, level, school and description cannot be changed on screen
- **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. 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.
- **Damage choices:** the drop-down offers "No damage", then 1 to 6, 8 or 10
dice of d4, d6, d8, d10 or d12 (1d4 up to 10d12), plus the three unusual
+20 -10
View File
@@ -37,7 +37,11 @@ This describes the code as it stands. The formulas live in
## 2. The weapon pool
The pool is the "Weapons Pool" card on the campaign page (DM only).
The pool is the "Weapons" part of the campaign page (DM only). It lists each
weapon as a short row: its name, base damage, starting level and what it does
while equipped. The list has a search box, shows ten to a page, and clicking a
row opens the weapon's **pop-out**, which shows everything below and is where
it is changed.
| Field | What it is |
|---|---|
@@ -48,17 +52,23 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Starts at level | 1 to 9. The lowest level this weapon can be (section 5) |
| Growth | Standard, Powerful or Legendary: how fast the dice climb per level (section 6) |
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…) but not shown or used anywhere |
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…). Shown in the weapon's pop-out, and not used anywhere |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note. Works exactly as for items ([ITEMS.md](ITEMS.md), section 5) |
- **Adding:** by hand with the "+ Add Weapon" form, or from the library.
- **Effects:** the "Effects" button on a weapon's row. 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 attack or damage.
- **Changing:** starting level, growth and the starting-option tick box can be
changed in the table. Name, damage, type and ability cannot be changed on
screen once created (the API accepts it); remove and re-add instead.
- **Removing:** the ✕ button. This takes the weapon away from **every
- **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; "+ 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
attack or damage.
- **Changing:** starting level, growth and the starting-option tick box are
changed in the pop-out, and saved as they are changed. Name, damage, type
and ability cannot be changed on screen once created (the API accepts it);
remove and re-add instead.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. This takes the weapon away from **every
character holding it** and off every encounter's reward list. There is no
undo.
- The same weapon can be added twice; nothing stops duplicate names.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.12.3",
"version": "1.13.1",
"type": "module",
"scripts": {
"dev": "vite",
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
// A pop-out over the page: a centred window on a wide screen, the whole
// screen on a phone. Escape, the ✕ and a click outside all close it, and the
// page behind stays where it was.
export function Modal({
title,
subtitle,
wide,
onClose,
children,
}: {
title: ReactNode;
subtitle?: ReactNode;
wide?: boolean;
onClose: () => void;
children: ReactNode;
}) {
const titleId = useId();
const windowRef = useRef<HTMLDivElement>(null);
// A click only closes if it both started and ended outside the window, so
// dragging to select text in a field never does.
const pressedOutside = useRef(false);
const close = useRef(onClose);
useEffect(() => {
close.current = onClose;
});
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close.current();
};
document.addEventListener('keydown', onKey);
const scroll = document.body.style.overflow;
document.body.style.overflow = 'hidden';
windowRef.current?.focus();
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = scroll;
opener?.focus?.();
};
}, []);
return createPortal(
<div
className="modal-backdrop"
onMouseDown={(e) => {
pressedOutside.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (pressedOutside.current && e.target === e.currentTarget) onClose();
}}
>
<div ref={windowRef} className={`modal${wide ? ' is-wide' : ''}`} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className="modal-head">
<div style={{ minWidth: 0 }}>
<h2 id={titleId} className="modal-title">
{title}
</h2>
{subtitle && <div className="combatant-sub">{subtitle}</div>}
</div>
<button type="button" className="btn btn-ghost" aria-label="Close" onClick={onClose}>
✕
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
}
+20 -3
View File
@@ -4,11 +4,14 @@ export function Pagination({
pageCount,
onPrev,
onNext,
onJump,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
// Given this, the page number is a list to pick any page from.
onJump?: (page: number) => void;
}) {
if (pageCount <= 1) return null;
return (
@@ -16,9 +19,23 @@ export function Pagination({
<button type="button" className="btn btn-ghost" onClick={onPrev} disabled={page <= 1}>
← Prev
</button>
<span className="pagination-status">
Page {page} of {pageCount}
</span>
{onJump && pageCount > 2 ? (
<label className="pagination-status flex-row gap-sm">
Page
<select aria-label="Go to page" value={page} onChange={(e) => onJump(Number(e.target.value))}>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
of {pageCount}
</label>
) : (
<span className="pagination-status">
Page {page} of {pageCount}
</span>
)}
<button type="button" className="btn btn-ghost" onClick={onNext} disabled={page >= pageCount}>
Next →
</button>
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react';
export interface Section {
id: string;
label: string;
icon: string;
// How many things are in it, shown beside its name.
count?: number;
}
// The parts of a page that shows one part at a time. A row of buttons on a
// wide screen; on a phone a single line that scrolls sideways, with the
// current part kept in view.
export function SectionNav({ sections, current, onSelect }: { sections: Section[]; current: string; onSelect: (id: string) => void }) {
const currentRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
currentRef.current?.scrollIntoView({ block: 'nearest', inline: 'center' });
}, [current]);
return (
<nav className="section-nav" aria-label="Parts of this campaign">
{sections.map((section) => (
<button
key={section.id}
ref={section.id === current ? currentRef : undefined}
type="button"
className={`jump-link${section.id === current ? ' is-active' : ''}`}
aria-current={section.id === current ? 'page' : undefined}
onClick={() => onSelect(section.id)}
>
<span aria-hidden>{section.icon}</span> {section.label}
{section.count !== undefined && <span className="section-count">{section.count}</span>}
</button>
))}
</nav>
);
}
@@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import { EntryRow } from './parts';
export interface SearchHit {
key: string;
title: string;
detail?: ReactNode;
badges?: ReactNode;
// Where it goes: a page of its own, or whatever `onPick` opens.
to?: string;
onPick?: () => void;
}
export interface SearchGroup {
label: string;
icon: string;
hits: SearchHit[];
}
// More than this of one kind and the rest are counted, not listed.
const SHOWN_PER_GROUP = 12;
// Everything in the campaign that a search found, a kind at a time.
export function CampaignSearch({ query, groups }: { query: string; groups: SearchGroup[] }) {
const found = groups.filter((g) => g.hits.length > 0);
const total = found.reduce((sum, g) => sum + g.hits.length, 0);
return (
<div className="card" aria-live="polite">
<div className="card-title">
<span>
{total} {total === 1 ? 'result' : 'results'} for "{query}"
</span>
</div>
{total === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>Nothing in this campaign matches. Try fewer words, or part of a name.</p>}
{found.map((group) => (
<div key={group.label} className="search-group">
<div className="section-label">
<span aria-hidden>{group.icon}</span> {group.label} ({group.hits.length})
</div>
<div className="entry-list">
{group.hits.slice(0, SHOWN_PER_GROUP).map((hit) => (
<EntryRow key={hit.key} name={hit.title} meta={hit.detail} badges={hit.badges} to={hit.to} onOpen={hit.onPick} />
))}
</div>
{group.hits.length > SHOWN_PER_GROUP && (
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
…and {group.hits.length - SHOWN_PER_GROUP} more. Add a word to narrow it down.
</p>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,363 @@
import { useState, type ReactNode } from 'react';
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
import { Modal } from '../Modal';
import { NumberInput } from '../NumberInput';
import { ErrorBanner } from '../Spinner';
import { EffectsEditor } from '../EffectsEditor';
import { WaresEditor } from '../Wares';
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
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 }) {
return (
<div className="stat-chip">
<span>{label}</span>
<b>{children}</b>
</div>
);
}
// The foot of every pop-out: taking the thing out of the campaign.
function RemoveButton({ name, warning, pending, onRemove }: { name: string; warning: string; pending: boolean; onRemove: () => void }) {
return (
<div className="modal-foot">
<button className="btn btn-danger" disabled={pending} onClick={() => window.confirm(`Remove ${name}? ${warning}`) && onRemove()}>
Remove from campaign
</button>
</div>
);
}
// A weapon or an item: the two share levels, growth and effects.
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 ?? []);
const [note, setNote] = useState(entry.effectNote ?? '');
const [effectsSaved, setEffectsSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
const weapon = 'damageType' in entry ? entry : null;
return (
<Modal title={entry.name} subtitle={weapon ? 'Weapon' : 'Item'} onClose={onClose}>
{added}
{error && <ErrorBanner message={error} />}
<div className="stat-chips">
{weapon ? (
<>
<Chip label="Base damage">
{weapon.damageDice} {weapon.damageType}
</Chip>
<Chip label="Attacks with">{ATTACK_ABILITY_LABELS[weapon.attackAbility]}</Chip>
{weapon.rangeNormal && (
<Chip label="Range">
{weapon.rangeNormal}
{weapon.rangeLong ? `/${weapon.rangeLong}` : ''} ft
</Chip>
)}
{weapon.properties.length > 0 && <Chip label="Properties">{weapon.properties.join(', ')}</Chip>}
</>
) : (
entry.damageDice && <Chip label="Base dice">{entry.damageDice}</Chip>
)}
</div>
{!weapon && 'description' in entry && entry.description && <p className="mt-sm" style={{ whiteSpace: 'pre-wrap' }}>{entry.description}</p>}
<div className="grid grid-cols-2 mt-md">
<Field label="Level it starts at">
{(id) => <StartLevelSelect id={id} value={entry.minLevel} onChange={(minLevel) => patch.mutate({ id: entry.id, minLevel }, { onError })} />}
</Field>
{entry.damageDice && (
<Field label="Growth per level">
{(id) => <ScalingSelect id={id} value={entry.damageScaling} onChange={(damageScaling) => patch.mutate({ id: entry.id, damageScaling }, { onError })} />}
</Field>
)}
</div>
<p className="combatant-sub mt-sm">
A character must be at least its level to use it.
{entry.damageDice &&
' The dice above are what it does at the level it starts at. When you level up a character\'s copy they grow one small step per level (1d4 → 1d6 → 1d8 → 1d10 → 1d12 → 2d6 → …): two steps for Powerful gear, three for Legendary.'}
</p>
<label className="checkbox-row mt-md">
<input type="checkbox" checked={entry.isStartingOption} onChange={(e) => patch.mutate({ id: entry.id, isStartingOption: e.target.checked }, { onError })} />
Starting option: a player may pick it while building a character
</label>
<div className="section-label mt-md">While equipped</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
+AC; a penalty is a negative number.
</p>
<EffectsEditor
effects={effects}
note={note}
onChange={(nextEffects, nextNote) => {
setEffects(nextEffects);
setNote(nextNote);
setEffectsSaved(false);
}}
/>
<div className="flex-row mt-sm">
<button
className="btn btn-primary"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: entry.id, effects, effectNote: note.trim() || null }, { onError, onSuccess: () => setEffectsSaved(true) })}
>
Save Effects
</button>
{effectsSaved && <span className="combatant-sub">Saved.</span>}
</div>
<RemoveButton name={entry.name} warning="Any character holding it loses it." pending={remove.isPending} onRemove={() => remove.mutate(entry.id, { onError, onSuccess: onClose })} />
</Modal>
);
}
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);
const onError = (err: unknown) => setError(extractErrorMessage(err));
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>}
{spell.range && <Chip label="Range">{spell.range}</Chip>}
{spell.duration && <Chip label="Duration">{spell.duration}</Chip>}
</div>
<p className="mt-sm" style={{ whiteSpace: 'pre-wrap' }}>{spell.description || 'No description.'}</p>
<div className="grid grid-cols-2 mt-md">
<Field label="Damage">
{(id) => <SpellDamageSelect id={id} spellName={spell.name} value={spell.damageDice} onChange={(damageDice) => patch.mutate({ id: spell.id, damageDice }, { onError })} />}
</Field>
</div>
<label className="checkbox-row mt-md">
<input type="checkbox" checked={spell.isStartingOption} onChange={(e) => patch.mutate({ id: spell.id, isStartingOption: e.target.checked }, { onError })} />
Starting option: a player may pick it while building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" checked={spell.revives} onChange={(e) => patch.mutate({ id: spell.id, revives: e.target.checked }, { onError })} />
Revives the dead: a character who can cast it may revive a dead ally on their turn in a battle
</label>
<RemoveButton name={spell.name} warning="Any character holding it loses it." pending={remove.isPending} onRemove={() => remove.mutate(spell.id, { onError, onSuccess: onClose })} />
</Modal>
);
}
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);
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
return (
<Modal title={editing ? `Editing ${monster.name}` : monster.name} subtitle="Monster" wide onClose={onClose}>
{added}
{error && <ErrorBanner message={error} />}
{editing ? (
<MonsterForm
monster={monster}
saving={update.isPending}
onCancel={() => setEditing(false)}
onSave={async (stats) => {
setError(null);
try {
await update.mutateAsync({ id: monster.id, ...stats });
setEditing(false);
} catch (err) {
onError(err);
throw err;
}
}}
/>
) : (
<>
<div className="stat-chips">
<Chip label="CR">{monster.challengeRating || '—'}</Chip>
<Chip label="AC">{monster.ac}</Chip>
<Chip label="HP">{monster.maxHp}</Chip>
<Chip label="Speed">{monster.speed}</Chip>
<Chip label="Attack">{fmtMod(monster.attackBonus)}</Chip>
<Chip label="Damage">{monster.damageDice || '—'}</Chip>
</div>
<p className="mono mt-sm" style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{abilityLine(monster)}</p>
{monster.specialTrait && (
<p>
<strong>Trait:</strong> {monster.specialTrait}
</p>
)}
{monster.notes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>{monster.notes}</p>}
<button className="btn" onClick={() => setEditing(true)}>
Edit stat block
</button>
<RemoveButton
name={monster.name}
warning="Copies already in encounters stay, but lose their stat block."
pending={remove.isPending}
onRemove={() => remove.mutate(monster.id, { onError, onSuccess: onClose })}
/>
</>
)}
</Modal>
);
}
// 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, 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) : '');
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
const rate = rateLabel(npc);
const hasStats = npc.ac !== null || npc.maxHp !== null || npc.attackBonus !== null || Boolean(npc.damageDice);
return (
<Modal
title={npc.name}
subtitle={
<span className="flex-row flex-wrap gap-sm">
{npc.role || 'NPC'}
<span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>
{rate && <span className="badge">💱 {rate}</span>}
</span>
}
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>}
{hasStats && (
<div className="stat-chips">
{npc.ac !== null && <Chip label="AC">{npc.ac}</Chip>}
{npc.maxHp !== null && <Chip label="HP">{npc.maxHp}</Chip>}
{npc.attackBonus !== null && <Chip label="Attack">{fmtMod(npc.attackBonus)}</Chip>}
{npc.damageDice && <Chip label="Damage">{npc.damageDice}</Chip>}
</div>
)}
<div className="section-label mt-md">Changes money</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
taken. Leave it empty for someone who does not change money.
</p>
<div className="flex-row flex-wrap gap-sm">
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
<span>gold =</span>
<span>$</span>
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
<button
className="btn"
disabled={!gold || !cash || patch.isPending}
onClick={() => patch.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
>
Save rate
</button>
{rate && (
<button
className="btn btn-ghost"
disabled={patch.isPending}
onClick={() =>
patch.mutate(
{ id: npc.id, exchangeGold: null, exchangeCash: null },
{
onError,
onSuccess: () => {
setGold('');
setCash('');
},
}
)
}
>
Stop changing money
</button>
)}
</div>
<div className="section-label mt-md">For sale</div>
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
<RemoveButton
name={npc.name}
warning="They leave every encounter they are in, and their wares go with them."
pending={remove.isPending}
onRemove={() => remove.mutate(npc.id, { onError, onSuccess: onClose })}
/>
</Modal>
);
}
// The pop-out for whichever weapon, item, spell, monster or NPC is open.
// Nothing shows if it is no longer in the campaign.
export function EntryDetail({
campaignId,
entry,
weapons,
items,
spells,
monsters,
npcs,
onAddAnother,
onClose,
}: {
campaignId: string;
entry: EntryRef;
weapons: Weapon[];
items: Item[];
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} added={added} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
}
case 'item': {
const item = find(items);
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} added={added} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
}
case 'monster': {
const monster = find(monsters);
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} added={added} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
}
}
}
@@ -0,0 +1,584 @@
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, StartLevelSelect } from './parts';
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
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,
hasLibrary = true,
onAdd,
intro,
children,
}: {
title: string;
addLabel: string;
hasLibrary?: boolean;
onAdd: (adding: Adding) => void;
intro: ReactNode;
children: ReactNode;
}) {
return (
<div className="card">
<div className="card-title">
<span>{title}</span>
<div className="flex-row gap-sm">
{hasLibrary && (
<button className="btn" onClick={() => onAdd('library')}>
📚 From Library
</button>
)}
<button className="btn btn-primary" onClick={() => onAdd('form')}>
{addLabel}
</button>
</div>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
{intro}
</p>
{children}
</div>
);
}
// 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<T extends { key: string; name: string }>({
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 (
<Modal title={title} subtitle="Each entry can be added once. This stays open so you can add several." wide onClose={onClose}>
{error && <ErrorBanner message={error} />}
{isError && <ErrorBanner message="Could not load the library." />}
{!library && !isError && <Spinner label="Fetching the library…" />}
{library && (
<LibraryPicker<T> items={pick(library)} taken={taken} adding={adding} onAdd={onAdd} placeholder={placeholder} renderMeta={renderMeta} renderDetail={renderDetail} />
)}
</Modal>
);
}
const StartingOption = () => (
<label className="checkbox-row" style={{ alignSelf: 'end', paddingBottom: '0.6rem' }}>
<input type="checkbox" name="isStartingOption" />
Starting option (a player may pick it while building a character)
</label>
);
const startingBadge = (entry: { isStartingOption: boolean }) => entry.isStartingOption && <span className="badge">Starting</span>;
export function WeaponsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Weapon>) {
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 (
<SectionCard
title="Weapons"
addLabel="+ Add Weapon"
onAdd={open}
intro={
<>
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.
</>
}
>
<EntryList
entries={entries}
noun="weapons"
emptyText="No weapons yet. Add one by hand, or copy some from the library."
searchText={(w) => [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' && (
<LibraryModal<LibraryWeapon>
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' && (
<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">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Damage at its starting level">{(id) => <input id={id} name="damageDice" placeholder="e.g. 1d8" required />}</Field>
<Field label="Damage type">{(id) => <input id={id} name="damageType" placeholder="e.g. slashing" required />}</Field>
<Field label="Attacks with">
{(id) => (
<select id={id} name="attackAbility" defaultValue="STR">
<option value="STR">STR</option>
<option value="DEX">DEX</option>
<option value="FINESSE">Finesse (the better of the two)</option>
</select>
)}
</Field>
<Field label="Level it starts at">{(id) => <StartLevelSelect id={id} name="minLevel" />}</Field>
<Field label="Growth per level">{(id) => <ScalingSelect id={id} name="damageScaling" />}</Field>
<StartingOption />
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createWeapon.isPending}>
Add Weapon
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Item>) {
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,
});
close();
onOpen({ kind: 'item', id: item.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<SectionCard
title="Items"
addLabel="+ Add Item"
onAdd={open}
intro={
<>
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).
</>
}
>
<EntryList
entries={entries}
noun="items"
emptyText="No items yet. Add rope, potions, armor, quest items…"
searchText={(i) => [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' && (
<LibraryModal<LibraryItem>
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' && (
<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">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Dice (optional)">{(id) => <input id={id} name="damageDice" placeholder="e.g. 2d4+2" maxLength={20} />}</Field>
<Field label="Description (optional)" style={{ gridColumn: '1 / -1' }}>
{(id) => <input id={id} name="description" />}
</Field>
<Field label="Level it starts at">{(id) => <StartLevelSelect id={id} name="minLevel" />}</Field>
<Field label="Growth of its dice per level">{(id) => <ScalingSelect id={id} name="damageScaling" />}</Field>
<StartingOption />
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createItem.isPending}>
Add Item
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Spell>) {
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',
});
close();
onOpen({ kind: 'spell', id: spell.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<SectionCard
title="Spells"
addLabel="+ Add Spell"
onAdd={open}
intro="The spells you hand out on each character's sheet. A character must be at least a spell's level to cast it. Open one to change its damage, or whether a new character may start with it."
>
<EntryList
entries={entries}
noun="spells"
emptyText="No spells yet. Add one by hand, or copy some from the library."
searchText={(s) => [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 && <span className="badge">Revives</span>}
{startingBadge(s)}
</>
)}
onOpen={(id) => onOpen({ kind: 'spell', id })}
/>
{adding === 'library' && (
<LibraryModal<LibrarySpell>
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' && (
<Modal title="Add a spell" onClose={close}>
{error && <ErrorBanner message={error} />}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-2">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Level">
{(id) => (
<select id={id} name="level" defaultValue={0}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((level) => (
<option key={level} value={level}>
{spellLevelLabel(level)}
</option>
))}
</select>
)}
</Field>
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
{(id) => <input id={id} name="description" maxLength={4000} required />}
</Field>
<StartingOption />
<label className="checkbox-row" style={{ alignSelf: 'end', paddingBottom: '0.6rem' }}>
<input type="checkbox" name="revives" />
Revives the dead (its caster may revive a dead ally on their turn in a battle)
</label>
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createSpell.isPending}>
Add Spell
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
export function MonstersSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Monster>) {
const createMonster = useCreateMonster(campaignId);
const addFromLibrary = useAddMonsterFromLibrary(campaignId);
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
return (
<SectionCard
title="Monsters"
addLabel="+ Add Monster"
onAdd={open}
intro="The stat blocks you bring into battles. Players never see them. Open one for the whole block, or to change it."
>
<EntryList
entries={entries}
noun="monsters"
emptyText="No monsters yet. Add one by hand, or copy some from the library."
searchText={(m) => [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' && (
<LibraryModal<LibraryMonster>
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' && (
<Modal title="Add a monster" wide onClose={close}>
{error && <ErrorBanner message={error} />}
<MonsterForm
saving={createMonster.isPending}
onSave={async (stats) => {
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;
}
}}
/>
</Modal>
)}
</SectionCard>
);
}
export function NpcsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Npc>) {
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 (
<SectionCard
title="NPCs"
addLabel="+ Add NPC"
hasLibrary={false}
onAdd={open}
intro="The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an NPC's stats. Open one to set what they sell and the rate they change gold and cash at; players see both when they meet the NPC in a quest."
>
<EntryList
entries={entries}
noun="NPCs"
emptyText="No NPCs yet."
searchText={(n) => [n.name, n.role, n.disposition, n.description, n.dmNotes]}
renderMeta={(n) => [n.role, n.description].filter(Boolean).join(' · ')}
renderBadges={(n) => (
<>
{rateLabel(n) && <span className="badge">💱 {rateLabel(n)}</span>}
{(n.wares?.length ?? 0) > 0 && <span className="badge">🛒 {n.wares?.length}</span>}
<span className={`badge badge-${n.disposition.toLowerCase()}`}>{n.disposition}</span>
</>
)}
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={close}>
{error && <ErrorBanner message={error} />}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-2">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Role">{(id) => <input id={id} name="role" placeholder="innkeeper, captain…" maxLength={80} />}</Field>
<Field label="Attitude toward the party">
{(id) => (
<select id={id} name="disposition" defaultValue="NEUTRAL">
<option value="FRIENDLY">Friendly</option>
<option value="NEUTRAL">Neutral</option>
<option value="HOSTILE">Hostile</option>
</select>
)}
</Field>
</div>
<Field label="What the party sees and knows (shown to players)" style={{ marginTop: '0.75rem' }}>
{(id) => <textarea id={id} name="description" rows={2} className="textarea-block" />}
</Field>
<Field label="Secrets and motives (only you see this)" style={{ marginTop: '0.75rem' }}>
{(id) => <textarea id={id} name="dmNotes" rows={2} className="textarea-block" />}
</Field>
<div className="section-label mt-md">Combat stats (optional)</div>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))' }}>
<Field label="AC">{(id) => <NumberInput id={id} name="ac" min={0} max={50} />}</Field>
<Field label="HP">{(id) => <NumberInput id={id} name="maxHp" min={1} max={100000} />}</Field>
<Field label="Attack bonus">{(id) => <NumberInput id={id} name="attackBonus" min={-20} max={50} />}</Field>
<Field label="Damage">{(id) => <input id={id} name="damageDice" placeholder="e.g. 1d6" maxLength={20} />}</Field>
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createNpc.isPending}>
Add NPC
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
+363
View File
@@ -0,0 +1,363 @@
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { NumberInput } from '../NumberInput';
import { Pagination } from '../Paginated';
import { usePagination } from '../../hooks/usePagination';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
import type { Monster } from '../../api/types';
// A labelled field in a pop-out form. The child is handed the id its label points at.
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
const id = useId();
return (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={id}>{label}</label>
{children(id)}
</div>
);
}
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
return (
<select
id={id}
name={name}
aria-label="How fast the dice grow per level"
title="How fast the dice grow each time a held copy is levelled up"
{...(onChange ? { value, onChange: (e) => onChange(Number(e.target.value)) } : { defaultValue: 1 })}
>
{Object.entries(DAMAGE_SCALING_LABELS).map(([scaling, label]) => (
<option key={scaling} value={scaling}>
{label}
</option>
))}
</select>
);
}
const GEAR_LEVELS = Array.from({ length: MAX_GEAR_LEVEL }, (_, i) => i + 1);
// The level a weapon or item starts at. A held copy is never below it, and a
// character must be at least this level to use it.
export function StartLevelSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (minLevel: number) => void }) {
return (
<select
id={id}
name={name}
aria-label="Level it starts at"
title="The level this starts at — a character must be at least this level to use it"
{...(onChange ? { value, onChange: (e) => onChange(Number(e.target.value)) } : { defaultValue: 1 })}
>
{GEAR_LEVELS.map((level) => (
<option key={level} value={level}>
Starts at Lv {level}
</option>
))}
</select>
);
}
// A spell's damage is picked from a list rather than typed. A value that is
// not in the list (set through the API, say) is still shown and kept.
export function SpellDamageSelect({
id,
name,
spellName,
value,
onChange,
}: {
id?: string;
name?: string;
spellName?: string;
value?: string | null;
onChange?: (damageDice: string | null) => void;
}) {
const known = [...SPELL_DAMAGE_GROUPS.flatMap((g) => g.options), ...SPELL_DAMAGE_SPECIALS];
const others = value && !known.includes(value) ? [...SPELL_DAMAGE_SPECIALS, value] : SPELL_DAMAGE_SPECIALS;
return (
<select
id={id}
name={name}
aria-label={spellName ? `${spellName} damage` : 'Damage'}
{...(onChange ? { value: value ?? '', onChange: (e) => onChange(e.target.value || null) } : { defaultValue: '' })}
>
<option value="">No damage</option>
{SPELL_DAMAGE_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((dice) => (
<option key={dice} value={dice}>
{dice}
</option>
))}
</optgroup>
))}
<optgroup label="Other">
{others.map((dice) => (
<option key={dice} value={dice}>
{dice}
</option>
))}
</optgroup>
</select>
);
}
// The whole stat block, field for field as a printed one gives it. Adds a
// monster, or edits one when `monster` is passed (its values fill the form).
export function MonsterForm({
monster,
saving,
onSave,
onCancel,
}: {
monster?: Monster;
saving: boolean;
// Rejects if the save failed, so the form keeps what was typed.
onSave: (data: MonsterInput) => Promise<void>;
onCancel?: () => void;
}) {
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={`monster-${name}`}>{label}</label>
{input}
</div>
);
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
field(
name,
label,
<NumberInput
id={`monster-${name}`}
name={name}
min={min}
max={max}
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
keepValue={fallback !== undefined}
required
/>
);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
const text = (key: string) => String(fd.get(key) || '').trim() || null;
const num = (key: string) => Number(fd.get(key));
try {
await onSave({
name: String(fd.get('name')).trim(),
challengeRating: text('challengeRating'),
ac: num('ac'),
maxHp: num('maxHp'),
speed: num('speed'),
attackBonus: num('attackBonus'),
damageDice: text('damageDice'),
strScore: num('strScore'),
dexScore: num('dexScore'),
conScore: num('conScore'),
intScore: num('intScore'),
wisScore: num('wisScore'),
chaScore: num('chaScore'),
specialTrait: text('specialTrait'),
notes: text('notes'),
});
} catch {
// Whoever opened the form shows the error; what was typed stays.
}
}}
>
<div className="grid grid-cols-3">
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
{field(
'challengeRating',
'CR (Challenge Rating)',
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
)}
{number('ac', 'AC (Armor Class)', 0, 50)}
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
{number('speed', 'Speed (feet)', 0, 1000, 30)}
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
{field(
'damageDice',
'Damage',
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
)}
</div>
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
</div>
{field(
'specialTrait',
'Special trait',
<input
id="monster-specialTrait"
name="specialTrait"
defaultValue={monster?.specialTrait ?? ''}
placeholder="The one thing that makes it different"
maxLength={500}
/>,
{ marginTop: '0.5rem' }
)}
{field(
'notes',
'Notes',
<textarea
id="monster-notes"
name="notes"
rows={3}
className="textarea-block"
defaultValue={monster?.notes ?? ''}
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
maxLength={2000}
/>,
{ marginTop: '0.5rem' }
)}
<div className="flex-row mt-md">
<button type="submit" className="btn btn-primary" disabled={saving}>
{monster ? 'Save Changes' : 'Add Monster'}
</button>
{onCancel && (
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
)}
</div>
</form>
);
}
// The built-in library, searched by name, with what the campaign already
// holds left out. It stays open after an add, for adding several in a row.
export function LibraryPicker<T extends { key: string; name: string }>({
items,
taken,
onAdd,
adding,
renderMeta,
renderDetail,
placeholder,
}: {
items: T[];
// What the campaign already holds: a library entry by one of these names is
// left out, since each can be added once.
taken: { name: string }[] | undefined;
onAdd: (key: string) => void;
adding: boolean;
renderMeta: (item: T) => string;
// A sentence on what the entry is or does, shown under its name.
renderDetail?: (item: T) => string | null;
placeholder: string;
}) {
const [query, setQuery] = useState('');
const takenNames = new Set((taken ?? []).map((t) => t.name.toLowerCase()));
const available = items.filter((i) => !takenNames.has(i.name.toLowerCase()));
const filtered = available.filter((i) => matchesSearch(query, i.name));
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(filtered, 8);
return (
<div>
<input type="search" placeholder={placeholder} aria-label={placeholder} value={query} onChange={(e) => setQuery(e.target.value)} style={{ width: '100%' }} />
<div className="mt-sm">
{pageItems.map((item) => (
<div key={item.key} className="flex-between" style={{ padding: '0.45rem 0', borderBottom: '1px solid var(--border-soft)', flexWrap: 'nowrap' }}>
<div style={{ minWidth: 0 }}>
<strong>{item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6, fontSize: '0.85rem' }}>{renderMeta(item)}</span>
{renderDetail?.(item) && <div style={{ fontSize: '0.9rem' }}>{renderDetail(item)}</div>}
</div>
<button className="btn" style={{ flex: 'none' }} disabled={adding} aria-label={`Add ${item.name}`} onClick={() => onAdd(item.key)}>
+ Add
</button>
</div>
))}
{filtered.length === 0 && (
<p style={{ color: 'var(--text-muted)' }}>
{available.length === 0 ? 'Everything in the library is already in this campaign.' : 'No matches.'}
</p>
)}
</div>
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
</div>
);
}
// One line of a list: what it is and a few words about it. The rest is in
// the pop-out it opens, or on the page it links to when given `to`.
export function EntryRow({ name, meta, badges, onOpen, to }: { name: string; meta?: ReactNode; badges?: ReactNode; onOpen?: () => void; to?: string }) {
const inner = (
<>
<span className="entry-row-main">
<span className="entry-row-name">{name}</span>
{meta && <span className="entry-row-meta">{meta}</span>}
</span>
{badges && <span className="entry-row-badges">{badges}</span>}
<span className="entry-row-chevron" aria-hidden>
›
</span>
</>
);
return to ? (
<Link to={to} className="entry-row">
{inner}
</Link>
) : (
<button type="button" className="entry-row" onClick={onOpen}>
{inner}
</button>
);
}
// A campaign list as short rows: searched by a box of its own, paged, and
// each row opening its pop-out.
export function EntryList<T extends { id: string; name: string }>({
entries,
noun,
emptyText,
searchText,
renderMeta,
renderBadges,
onOpen,
}: {
entries: T[] | undefined;
// What the list holds, in the plural: "weapons".
noun: string;
emptyText: string;
// Everything about an entry that a search should find it by.
searchText: (entry: T) => (string | number | null | undefined)[];
renderMeta: (entry: T) => ReactNode;
renderBadges?: (entry: T) => ReactNode;
onOpen: (id: string) => void;
}) {
const [query, setQuery] = useState('');
const filtered = (entries ?? []).filter((entry) => matchesSearch(query, ...searchText(entry)));
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(filtered, 10);
if (entries && entries.length === 0) return <p className="mt-sm" style={{ color: 'var(--text-muted)' }}>{emptyText}</p>;
return (
<>
<input
type="search"
className="mt-sm"
style={{ width: '100%' }}
placeholder={`Search the ${noun} in this campaign…`}
aria-label={`Search the ${noun} in this campaign`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
<div className="entry-list mt-sm">
{pageItems.map((entry) => (
<EntryRow key={entry.id} name={entry.name} meta={renderMeta(entry)} badges={renderBadges?.(entry)} onOpen={() => onOpen(entry.id)} />
))}
</div>
{entries && filtered.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No {noun} match "{query}".</p>}
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
</>
);
}
+216
View File
@@ -1753,3 +1753,219 @@ table.data-table tbody tr.is-mine:hover {
color: var(--success-strong);
background: rgba(95, 138, 79, 0.18);
}
/* ---------------------------------------------------------------------- */
/* The campaign page: one part at a time, picked from a row of buttons; */
/* lists as short rows; and the pop-out a row opens */
/* ---------------------------------------------------------------------- */
.campaign-search {
width: 100%;
margin-bottom: 0.75rem;
}
.section-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.section-nav .jump-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.jump-link.is-active {
border-color: var(--accent);
color: var(--accent-strong);
background: var(--accent-soft);
}
.section-count {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--text-dim);
}
.section-block + .section-block {
margin-top: 2rem;
}
.search-group + .search-group {
margin-top: 1.25rem;
}
.entry-list {
border: 1px solid var(--border-soft);
border-radius: var(--radius);
overflow: hidden;
}
.entry-list:empty {
display: none;
}
.entry-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-soft);
color: var(--text);
text-align: left;
cursor: pointer;
}
.entry-row:last-child {
border-bottom: none;
}
.entry-row:hover,
.entry-row:focus-visible {
background: var(--bg-card-hover);
text-decoration: none;
outline: none;
}
.entry-row-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.entry-row-name {
font-family: var(--font-display);
color: var(--accent-strong);
}
/* A few words only: the whole of it is in the pop-out. */
.entry-row-meta {
color: var(--text-muted);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-row-badges {
flex: none;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.3rem;
max-width: 45%;
}
.entry-row-chevron {
flex: none;
color: var(--text-dim);
font-size: 1.3rem;
line-height: 1;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 96;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 5vh 1rem;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(2px);
}
.modal {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: 90dvh;
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-deep);
}
.modal:focus {
outline: none;
}
.modal.is-wide {
width: min(860px, 100%);
}
.modal-head {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 0.6rem 0.75rem 1.2rem;
border-bottom: 1px solid var(--border-soft);
}
.modal-title {
font-size: 1.2rem;
margin: 0;
overflow-wrap: anywhere;
}
.modal-body {
overflow-y: auto;
padding: 1rem 1.2rem 1.25rem;
}
.modal-body .field input,
.modal-body .field select {
width: 100%;
}
.modal-foot {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-soft);
display: flex;
justify-content: flex-end;
}
.pagination .pagination-status > select {
min-width: 0 !important;
padding: 0.3rem 0.5rem;
}
@media (max-width: 900px) {
/* The parts of the campaign are one line that scrolls sideways, edge to edge. */
.section-nav {
flex-wrap: nowrap;
overflow-x: auto;
margin: 0 -0.85rem 1rem;
padding: 0 0.85rem 0.35rem;
scrollbar-width: none;
}
.section-nav::-webkit-scrollbar {
display: none;
}
.section-nav .jump-link {
flex: none;
}
.entry-row {
padding: 0.75rem 0.7rem;
}
/* A pop-out is the whole screen, over the bottom bar too. */
.modal-backdrop {
padding: 0;
}
.modal,
.modal.is-wide {
width: 100%;
height: 100dvh;
max-height: none;
border: none;
border-radius: 0;
}
.modal-head {
padding: 0.7rem 0.4rem 0.6rem 0.85rem;
}
.modal-body {
flex: 1;
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);
}
+35
View File
@@ -0,0 +1,35 @@
import { ABILITY_ORDER } from './previewCalc';
import { formatMoney } from './format';
import type { Monster, Npc } from '../api/types';
// One thing in a campaign's lists that opens in a pop-out.
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'>;
export type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
export const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
// "STR 8 · DEX 14 · …", in stat-block order.
export function abilityLine(scores: AbilityScores): string {
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
}
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
export function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
if (!npc.exchangeGold || !npc.exchangeCash) return null;
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
}
// Whether every word of a search is somewhere in the text given: "fire bolt"
// finds "Fire Bolt", and "bolt fire" does too.
export function matchesSearch(query: string, ...fields: (string | number | null | undefined)[]): boolean {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
return words.every((word) => text.includes(word));
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -689,7 +689,7 @@ export function CharacterSheetPage() {
</div>
))}
{character.weapons.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No weapons. The DM hands these out.</p>}
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} onJump={weaponsPage.setPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -744,7 +744,7 @@ export function CharacterSheetPage() {
</div>
))}
{character.spells.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No spells known. The DM grants these.</p>}
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} onJump={spellsPage.setPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -836,7 +836,7 @@ export function CharacterSheetPage() {
</div>
))}
{character.inventoryItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>Nothing carried. The DM hands out items.</p>}
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} onJump={itemsPage.setPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedItemId} onChange={(e) => setPickedItemId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -955,7 +955,7 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} onJump={ledgerPage.setPage} />
</div>
</>
)}
@@ -1059,7 +1059,7 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
</div>
</div>
+2 -2
View File
@@ -22,7 +22,7 @@ export function PartyOverviewPage() {
const { data, isLoading: campaignLoading, error: campaignError } = useCampaign(campaignId);
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
const { pageItems, page, pageCount, prevPage, nextPage } = usePagination(characters, 12);
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
if (campaignError || charsError || !data) return <ErrorBanner message="Could not load the party overview." />;
@@ -101,7 +101,7 @@ export function PartyOverviewPage() {
</table>
</div>
)}
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
</div>
);
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.12.3",
"version": "1.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.12.3",
"version": "1.13.1",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.12.3",
"version": "1.13.1",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.12.3",
"version": "1.13.1",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.12.3",
"version": "1.13.1",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [