Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a867fd1a1 | ||
|
|
37c561c5d3 |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.12.3",
|
||||
"version": "1.13.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
+4
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.12.3",
|
||||
"version": "1.13.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+474
-1364
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user