V1.10.1: a phone-friendly layout, easier navigation, paged encounters
- The main destinations are always on screen: Campaigns, Campaign and Party (for the campaign last visited, from any page) and Account. They are links along the top bar on a wide screen, and a bar along the bottom on a phone or small tablet (900px or less). - Nothing floats over the page on a phone: the dice roller is the last button of the bottom bar, and quick stats sits in the top bar. - The campaign page and the character sheet start with a row of links to their sections. - On a small screen every table is a stack of labelled cards, rows of controls wrap, fields shrink to fit, and things to tap are bigger. - The encounter list is paged, ten to a page, as is the archived list. Moving an encounter still works across pages. - Text boxes with no type given, and native checkboxes and dropdowns, now match the dark theme. Long monster notes and item descriptions wrap inside their table cell. - All packages at version 1.10.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,25 @@ version was built from. The app has since moved on in places (gear levels,
|
|||||||
the builder's story steps, picking a motivation); the documents above are the
|
the builder's story steps, picking a motivation); the documents above are the
|
||||||
current behaviour.
|
current behaviour.
|
||||||
|
|
||||||
|
## Getting around
|
||||||
|
|
||||||
|
The same four destinations are always on screen: **Campaigns** (the list),
|
||||||
|
**Campaign** and **Party** (for the campaign you were last in, from any page,
|
||||||
|
including a character sheet or an encounter) and **Account**.
|
||||||
|
|
||||||
|
- On a wide screen they are links along the top bar, and the dice roller
|
||||||
|
floats at the bottom left.
|
||||||
|
- On a phone or small tablet (900px wide or less) they are a bar along the
|
||||||
|
bottom of the screen, with the **dice roller** as its last button. Nothing
|
||||||
|
floats over the page: on a character sheet the ⚡ quick stats button sits in
|
||||||
|
the top bar.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
|
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
|
||||||
@@ -85,7 +104,7 @@ and then restart the backend. It applies new migrations **and** regenerates
|
|||||||
the Prisma client; without the second half the server keeps running with the
|
the Prisma client; without the second half the server keeps running with the
|
||||||
old data model and pages that use anything new fail to load (for example
|
old data model and pages that use anything new fail to load (for example
|
||||||
"Could not load this encounter"). Change your password from the account page
|
"Could not load this encounter"). Change your password from the account page
|
||||||
(click your name in the top bar).
|
("Account" in the navigation).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
|
|||||||
+3
-3
@@ -646,7 +646,7 @@ character in the same campaign.
|
|||||||
| Party overview | One table of every character with an HP bar, AC, gold and location. |
|
| Party overview | One table of every character with an HP bar, AC, gold and location. |
|
||||||
| Character sheet | Everything in this document. |
|
| Character sheet | Everything in this document. |
|
||||||
| Character builder | A character that is still being built (section 2). |
|
| Character builder | A character that is still being built (section 2). |
|
||||||
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
|
| Quick stats drawer (the ⚡ button: bottom right, or in the top bar on a phone) | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
|
||||||
| Encounter page | Characters taking part in a battle or quest (next section). |
|
| Encounter page | Characters taking part in a battle or quest (next section). |
|
||||||
|
|
||||||
Changes are pushed live to everyone who has the campaign open, so sheets and
|
Changes are pushed live to everyone who has the campaign open, so sheets and
|
||||||
@@ -789,8 +789,8 @@ calculations for them.
|
|||||||
- Armor and shields as items (only the single armor bonus number)
|
- Armor and shields as items (only the single armor bonus number)
|
||||||
- Weapon proficiency (everyone is proficient with everything)
|
- Weapon proficiency (everyone is proficient with everything)
|
||||||
- Rolling attacks, damage, saves or checks for a character (the dice roller,
|
- Rolling attacks, damage, saves or checks for a character (the dice roller,
|
||||||
bottom left of every page, is a plain roller that knows nothing about the
|
bottom left of every page, or the last button of the bottom bar on a phone,
|
||||||
sheet)
|
is a plain roller that knows nothing about the sheet)
|
||||||
- Conditions on the sheet, and resting
|
- Conditions on the sheet, and resting
|
||||||
- Automatic levelling from XP
|
- Automatic levelling from XP
|
||||||
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
||||||
|
|||||||
+5
-1
@@ -88,6 +88,10 @@ 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
|
date instead; that choice is remembered per browser and changes nothing for
|
||||||
anyone else. ▲ and ▼ only show while sorting by campaign order.
|
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 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.
|
||||||
|
|
||||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
||||||
away without changing it. It moves to the DM's "Archived" section and
|
away without changing it. It moves to the DM's "Archived" section and
|
||||||
disappears from the players' list; a player whose character is in it can still
|
disappears from the players' list; a player whose character is in it can still
|
||||||
@@ -442,7 +446,7 @@ People outside the campaign cannot open its encounters.
|
|||||||
## 13. What is not modelled
|
## 13. What is not modelled
|
||||||
|
|
||||||
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
|
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
|
||||||
left of every page)
|
left of every page, or in the bottom bar on a phone)
|
||||||
- Applying an attack's damage automatically
|
- Applying an attack's damage automatically
|
||||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||||
character's turn
|
character's turn
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// A row of links to the sections of a long page. It finds them itself: any
|
||||||
|
// element with a `data-jump="Label"` attribute that is on the page right now
|
||||||
|
// gets a link, so sections only some people see need no special handling.
|
||||||
|
export function JumpNav() {
|
||||||
|
const [labels, setLabels] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Sections come and go as data loads, so the page is watched for them.
|
||||||
|
useEffect(() => {
|
||||||
|
const look = () => {
|
||||||
|
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
|
||||||
|
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(look);
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
|
||||||
|
look();
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (labels.length < 3) return null;
|
||||||
|
return (
|
||||||
|
<nav className="jump-nav" aria-label="Sections of this page">
|
||||||
|
{labels.map((label) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className="jump-link"
|
||||||
|
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,39 @@
|
|||||||
import { Link, Outlet } from 'react-router-dom';
|
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||||
import { DiceRoller } from './DiceRoller';
|
import { DiceRoller } from './DiceRoller';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
// The campaign pages say which campaign they are in their address; sheets
|
||||||
|
// and encounters report theirs themselves (useRememberCampaign).
|
||||||
|
const onCampaign = useMatch('/campaigns/:campaignId/*');
|
||||||
|
useRememberCampaign(onCampaign?.params.campaignId);
|
||||||
|
const campaignId = useLastCampaignId();
|
||||||
|
|
||||||
|
// The same destinations twice: along the top bar on a wide screen, and as a
|
||||||
|
// bar along the bottom, in reach of a thumb, on a phone.
|
||||||
|
const destinations = [
|
||||||
|
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
|
||||||
|
...(campaignId
|
||||||
|
? [
|
||||||
|
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
|
||||||
|
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ to: '/account', end: true, icon: '👤', label: 'Account' },
|
||||||
|
];
|
||||||
|
const links = (className: string) =>
|
||||||
|
destinations.map((d) => (
|
||||||
|
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
|
||||||
|
<span className="nav-icon" aria-hidden>
|
||||||
|
{d.icon}
|
||||||
|
</span>
|
||||||
|
<span>{d.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -14,22 +43,26 @@ export function Layout() {
|
|||||||
</span>
|
</span>
|
||||||
The Sunken Reach
|
The Sunken Reach
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="topbar-nav">
|
{user && (
|
||||||
{user && (
|
<nav className="topbar-nav" aria-label="Main">
|
||||||
|
<div className="topbar-links">{links('topbar-link')}</div>
|
||||||
<div className="topbar-user">
|
<div className="topbar-user">
|
||||||
<Link to="/account" title="Account settings">
|
<span className="topbar-name">{user.displayName}</span>
|
||||||
{user.displayName}
|
|
||||||
</Link>
|
|
||||||
<button className="btn btn-ghost" onClick={logout}>
|
<button className="btn btn-ghost" onClick={logout}>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</nav>
|
||||||
</nav>
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
{user && (
|
||||||
|
<nav className="bottom-nav" aria-label="Main">
|
||||||
|
{links('bottom-nav-link')}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
<DiceRoller />
|
<DiceRoller />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+419
-4
@@ -1,4 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
|
/* Dark throughout, so the browser draws its own controls dark too. */
|
||||||
|
color-scheme: dark;
|
||||||
--bg: #131009;
|
--bg: #131009;
|
||||||
--bg-elevated: #1b160e;
|
--bg-elevated: #1b160e;
|
||||||
--bg-card: #221b12;
|
--bg-card: #221b12;
|
||||||
@@ -347,10 +349,8 @@ textarea {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'],
|
/* Every text-like field, including an <input> with no type given. */
|
||||||
input[type='email'],
|
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
|
||||||
input[type='password'],
|
|
||||||
input[type='number'],
|
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -1290,3 +1290,418 @@ button.death-pip {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Navigation: links along the top bar on a wide screen, a bar along the */
|
||||||
|
/* bottom on a phone; and a row of links to the sections of a long page */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.topbar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.topbar-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.topbar-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.topbar-link.is-active {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.topbar-name {
|
||||||
|
max-width: 14ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.jump-link {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.jump-link:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
/* A section jumped to stops below the sticky top bar, not under it. */
|
||||||
|
[data-jump] {
|
||||||
|
scroll-margin-top: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A table cell that holds a sentence rather than a figure may wrap. */
|
||||||
|
table.data-table td.cell-title,
|
||||||
|
table.data-table td.cell-wide {
|
||||||
|
white-space: normal;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Small screens. Nothing may be wider than the screen: rows of controls */
|
||||||
|
/* wrap, fields shrink to fit, tables become stacked cards, and the main */
|
||||||
|
/* destinations move to a bar along the bottom. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body,
|
||||||
|
#root,
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
/* A field never forces its row wider than the space there is. */
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
:root {
|
||||||
|
--bottom-nav-height: calc(3.6rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
--dice-slot: 4.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1.5rem);
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.topbar-brand {
|
||||||
|
font-size: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.topbar-links,
|
||||||
|
.topbar-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.topbar-nav {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 70;
|
||||||
|
display: flex;
|
||||||
|
height: var(--bottom-nav-height);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
background: linear-gradient(180deg, #1e1810 0%, #120e08 100%);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.bottom-nav-link {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-top: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.bottom-nav-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.bottom-nav-link .nav-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.bottom-nav-link.is-active {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-top-color: var(--accent-strong);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nothing floats over the page on a phone. The dice roller takes the last
|
||||||
|
place in the bottom bar, and quick stats a place in the top bar. */
|
||||||
|
.bottom-nav {
|
||||||
|
padding-right: var(--dice-slot);
|
||||||
|
}
|
||||||
|
.fab-dice {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 71;
|
||||||
|
width: var(--dice-slot);
|
||||||
|
height: var(--bottom-nav-height);
|
||||||
|
padding: 0 0 env(safe-area-inset-bottom, 0px);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.fab-dice::after {
|
||||||
|
content: 'Dice';
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.fab-dice:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.dice-panel {
|
||||||
|
left: 0.85rem;
|
||||||
|
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
||||||
|
width: calc(100vw - 1.7rem);
|
||||||
|
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
|
||||||
|
}
|
||||||
|
.fab-quickstats {
|
||||||
|
top: 0.3rem;
|
||||||
|
bottom: auto;
|
||||||
|
right: 6.4rem;
|
||||||
|
z-index: 31;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* The brand makes room for it. */
|
||||||
|
.app-shell:has(.fab-quickstats) .topbar-brand {
|
||||||
|
max-width: calc(100vw - 11rem);
|
||||||
|
}
|
||||||
|
.drawer {
|
||||||
|
width: 100vw;
|
||||||
|
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1rem);
|
||||||
|
z-index: 95;
|
||||||
|
}
|
||||||
|
.drawer-backdrop {
|
||||||
|
z-index: 94;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 0.9rem 0.85rem;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rows of controls wrap instead of running off the side, and what is in
|
||||||
|
them may shrink: an inline min-width never beats the screen. */
|
||||||
|
.flex-row,
|
||||||
|
.flex-between,
|
||||||
|
.detail-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.flex-row > *,
|
||||||
|
.flex-between > * {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.flex-row > input,
|
||||||
|
.flex-row > select,
|
||||||
|
.flex-row > textarea,
|
||||||
|
.flex-row > a,
|
||||||
|
.flex-between > a {
|
||||||
|
min-width: min(100%, 8rem) !important;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
white-space: normal;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
}
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.grid-cols-3 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
|
||||||
|
}
|
||||||
|
.choice-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.btn-roll {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.step-footer > * {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Things to tap are big enough to hit with a thumb. */
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='radio'] {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.death-pip {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn-target {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
}
|
||||||
|
.condition-chip {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
|
.condition-chip button {
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.info-tip-trigger {
|
||||||
|
font-size: 1.05em;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
.pick-chip {
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
}
|
||||||
|
.jump-link {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
}
|
||||||
|
.attack-bar {
|
||||||
|
top: 3.4rem;
|
||||||
|
max-height: 55dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
[data-jump] {
|
||||||
|
scroll-margin-top: 3.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables: every row becomes a card. The first cell is its heading; the
|
||||||
|
rest carry their column's name from data-label. */
|
||||||
|
.table-wrap {
|
||||||
|
overflow: visible;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
table.data-table,
|
||||||
|
table.data-table tbody {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
table.data-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
table.data-table tbody tr {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem 0.9rem;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
table.data-table tbody tr:last-child {
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
table.data-table tbody tr:hover {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
table.data-table td {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
table.data-table td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
table.data-table td.cell-title,
|
||||||
|
table.data-table td.cell-wide {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
table.data-table td.cell-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
table.data-table td[data-label]::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
table.data-table td.cell-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The narrowest phones: five places in the bottom bar still fit. */
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
:root {
|
||||||
|
--dice-slot: 3.4rem;
|
||||||
|
}
|
||||||
|
.bottom-nav-link,
|
||||||
|
.fab-dice::after {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Between a phone and a full desktop the top bar drops the name to stay on one line. */
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.topbar-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.topbar-nav {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
// The campaign the user was last in, so the navigation can always offer a way
|
||||||
|
// back to it — from a character sheet or an encounter, whose addresses don't
|
||||||
|
// say which campaign they belong to. Kept per browser.
|
||||||
|
const KEY = 'dnd_last_campaign';
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function read(): string | null {
|
||||||
|
return localStorage.getItem(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetCampaign() {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLastCampaignId(): string | null {
|
||||||
|
return useSyncExternalStore(subscribe, read);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call from any page that knows which campaign it is showing.
|
||||||
|
export function useRememberCampaign(campaignId: string | null | undefined) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!campaignId || read() === campaignId) return;
|
||||||
|
localStorage.setItem(KEY, campaignId);
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}, [campaignId]);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import { useAuth } from '../auth/AuthContext';
|
|||||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
import { NumberInput } from '../components/NumberInput';
|
import { NumberInput } from '../components/NumberInput';
|
||||||
|
import { JumpNav } from '../components/JumpNav';
|
||||||
import { LifeBadge } from '../components/DeathSaves';
|
import { LifeBadge } from '../components/DeathSaves';
|
||||||
import { usePagination, Pagination } from '../components/Paginated';
|
import { usePagination, Pagination } from '../components/Paginated';
|
||||||
import { WaresEditor } from '../components/Wares';
|
import { WaresEditor } from '../components/Wares';
|
||||||
@@ -134,7 +135,7 @@ function PoolRowControls({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td>
|
<td data-label="Starting option">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${entry.name} is a starting option`}
|
aria-label={`${entry.name} is a starting option`}
|
||||||
@@ -142,7 +143,7 @@ function PoolRowControls({
|
|||||||
onChange={(e) => onToggleStarting(e.target.checked)}
|
onChange={(e) => onToggleStarting(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="cell-actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
aria-label={`Remove ${entry.name}`}
|
aria-label={`Remove ${entry.name}`}
|
||||||
@@ -570,6 +571,8 @@ export function CampaignHomePage() {
|
|||||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||||
|
const encountersPage = usePagination(shownEncounters, 10);
|
||||||
|
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||||
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
||||||
const sharedNotesPage = usePagination(sharedNotes, 6);
|
const sharedNotesPage = usePagination(sharedNotes, 6);
|
||||||
@@ -676,8 +679,10 @@ export function CampaignHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<JumpNav />
|
||||||
|
|
||||||
{isDM && (
|
{isDM && (
|
||||||
<div className="card">
|
<div data-jump="Settings" className="card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Campaign Settings</span>
|
<span>Campaign Settings</span>
|
||||||
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
|
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
|
||||||
@@ -810,7 +815,7 @@ export function CampaignHomePage() {
|
|||||||
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
|
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
|
||||||
|
|
||||||
{!isDM && myCharacters.length > 0 && (
|
{!isDM && myCharacters.length > 0 && (
|
||||||
<div className="mt-lg">
|
<div data-jump="My characters" className="mt-lg">
|
||||||
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
||||||
{myCharacters.map((c) => (
|
{myCharacters.map((c) => (
|
||||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||||
@@ -818,7 +823,7 @@ export function CampaignHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-lg">
|
<div data-jump={isDM ? 'Characters' : 'Party'} className="mt-lg">
|
||||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,7 +851,7 @@ export function CampaignHomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-lg">
|
<div data-jump="Encounters" className="mt-lg">
|
||||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||||
{liveEncounters.length > 1 && (
|
{liveEncounters.length > 1 && (
|
||||||
@@ -888,13 +893,17 @@ export function CampaignHomePage() {
|
|||||||
)}
|
)}
|
||||||
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
|
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
|
||||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||||
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
|
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it; moving past the
|
||||||
|
top or bottom of a page carries an encounter onto the next one.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{encounters && liveEncounters.length === 0 && (
|
{encounters && liveEncounters.length === 0 && (
|
||||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||||
)}
|
)}
|
||||||
{shownEncounters.map((enc, index) => (
|
{encountersPage.pageItems.map((enc) => {
|
||||||
|
// Its place in the whole list, not on this page: that is what moving it changes.
|
||||||
|
const index = shownEncounters.indexOf(enc);
|
||||||
|
return (
|
||||||
<div key={enc.id} className="card flex-between flex-wrap">
|
<div key={enc.id} className="card flex-between flex-wrap">
|
||||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||||
@@ -939,7 +948,9 @@ export function CampaignHomePage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
<Pagination page={encountersPage.page} pageCount={encountersPage.pageCount} onPrev={encountersPage.prevPage} onNext={encountersPage.nextPage} />
|
||||||
|
|
||||||
{isDM && archivedEncounters.length > 0 && (
|
{isDM && archivedEncounters.length > 0 && (
|
||||||
<div className="mt-md">
|
<div className="mt-md">
|
||||||
@@ -952,7 +963,7 @@ export function CampaignHomePage() {
|
|||||||
{showArchivedEncounters && (
|
{showArchivedEncounters && (
|
||||||
<>
|
<>
|
||||||
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
|
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
|
||||||
{archivedEncounters.map((enc) => (
|
{archivedEncountersPage.pageItems.map((enc) => (
|
||||||
<div key={enc.id} className="card flex-between flex-wrap">
|
<div key={enc.id} className="card flex-between flex-wrap">
|
||||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||||
@@ -979,6 +990,12 @@ export function CampaignHomePage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<Pagination
|
||||||
|
page={archivedEncountersPage.page}
|
||||||
|
pageCount={archivedEncountersPage.pageCount}
|
||||||
|
onPrev={archivedEncountersPage.prevPage}
|
||||||
|
onNext={archivedEncountersPage.nextPage}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -986,7 +1003,7 @@ export function CampaignHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isDM && locations && locations.length > 0 && (
|
{!isDM && locations && locations.length > 0 && (
|
||||||
<div className="mt-lg card">
|
<div data-jump="Places" className="mt-lg card">
|
||||||
<div className="card-title">Places You've Been</div>
|
<div className="card-title">Places You've Been</div>
|
||||||
{locations.map((loc) => (
|
{locations.map((loc) => (
|
||||||
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||||
@@ -998,7 +1015,7 @@ export function CampaignHomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isDM && (
|
{isDM && (
|
||||||
<div className="mt-lg card">
|
<div data-jump="NPCs" className="mt-lg card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>NPCs</span>
|
<span>NPCs</span>
|
||||||
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
|
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
|
||||||
@@ -1100,7 +1117,7 @@ export function CampaignHomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isDM && (
|
{isDM && (
|
||||||
<div className="mt-lg card">
|
<div data-jump="Locations" className="mt-lg card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Locations</span>
|
<span>Locations</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1144,7 +1161,7 @@ export function CampaignHomePage() {
|
|||||||
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
|
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-sm card">
|
<div data-jump="Weapons" className="mt-sm card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Weapons Pool</span>
|
<span>Weapons Pool</span>
|
||||||
<div className="flex-row gap-sm">
|
<div className="flex-row gap-sm">
|
||||||
@@ -1218,12 +1235,12 @@ export function CampaignHomePage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{weaponsPage.pageItems.map((w) => (
|
{weaponsPage.pageItems.map((w) => (
|
||||||
<tr key={w.id}>
|
<tr key={w.id}>
|
||||||
<td>{w.name}</td>
|
<td className="cell-title">{w.name}</td>
|
||||||
<td>{w.damageDice} {w.damageType}</td>
|
<td data-label="Base damage">{w.damageDice} {w.damageType}</td>
|
||||||
<td>
|
<td data-label="Level">
|
||||||
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
|
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Growth">
|
||||||
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
|
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
|
||||||
</td>
|
</td>
|
||||||
<PoolRowControls
|
<PoolRowControls
|
||||||
@@ -1239,7 +1256,7 @@ export function CampaignHomePage() {
|
|||||||
<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} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-md card">
|
<div data-jump="Items" className="mt-md card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Items Pool</span>
|
<span>Items Pool</span>
|
||||||
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
|
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
|
||||||
@@ -1294,13 +1311,13 @@ export function CampaignHomePage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{itemsPage.pageItems.map((item) => (
|
{itemsPage.pageItems.map((item) => (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>{item.name}</td>
|
<td className="cell-title">{item.name}</td>
|
||||||
<td>{item.description}</td>
|
<td className="cell-wide">{item.description}</td>
|
||||||
<td>{item.damageDice || '—'}</td>
|
<td data-label="Base dice">{item.damageDice || '—'}</td>
|
||||||
<td>
|
<td data-label="Level">
|
||||||
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
|
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Growth">
|
||||||
{item.damageDice ? (
|
{item.damageDice ? (
|
||||||
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
|
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
|
||||||
) : (
|
) : (
|
||||||
@@ -1321,7 +1338,7 @@ export function CampaignHomePage() {
|
|||||||
<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} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-md card">
|
<div data-jump="Spells" className="mt-md card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Spell List</span>
|
<span>Spell List</span>
|
||||||
<div className="flex-row gap-sm">
|
<div className="flex-row gap-sm">
|
||||||
@@ -1405,15 +1422,15 @@ export function CampaignHomePage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{spellsPage.pageItems.map((s) => (
|
{spellsPage.pageItems.map((s) => (
|
||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
<td>
|
<td className="cell-title">
|
||||||
<strong>{s.name}</strong>
|
<strong>{s.name}</strong>
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
<td data-label="Level" style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||||
<td>
|
<td data-label="Damage">
|
||||||
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Revives">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${s.name} revives the dead`}
|
aria-label={`${s.name} revives the dead`}
|
||||||
@@ -1434,7 +1451,7 @@ export function CampaignHomePage() {
|
|||||||
<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} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-md card">
|
<div data-jump="Monsters" className="mt-md card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Monsters</span>
|
<span>Monsters</span>
|
||||||
<div className="flex-row gap-sm">
|
<div className="flex-row gap-sm">
|
||||||
@@ -1507,7 +1524,7 @@ export function CampaignHomePage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{monstersPage.pageItems.map((m) => (
|
{monstersPage.pageItems.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td>
|
<td className="cell-title">
|
||||||
<strong>{m.name}</strong>
|
<strong>{m.name}</strong>
|
||||||
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
|
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
|
||||||
{m.specialTrait && (
|
{m.specialTrait && (
|
||||||
@@ -1517,13 +1534,13 @@ export function CampaignHomePage() {
|
|||||||
)}
|
)}
|
||||||
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
|
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td>{m.challengeRating || '—'}</td>
|
<td data-label="CR">{m.challengeRating || '—'}</td>
|
||||||
<td>{m.ac}</td>
|
<td data-label="AC">{m.ac}</td>
|
||||||
<td>{m.maxHp}</td>
|
<td data-label="HP">{m.maxHp}</td>
|
||||||
<td>{m.speed}</td>
|
<td data-label="Speed">{m.speed}</td>
|
||||||
<td>{fmtMod(m.attackBonus)}</td>
|
<td data-label="Attack">{fmtMod(m.attackBonus)}</td>
|
||||||
<td>{m.damageDice || '—'}</td>
|
<td data-label="Damage">{m.damageDice || '—'}</td>
|
||||||
<td>
|
<td className="cell-actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
aria-label={`Edit ${m.name}`}
|
aria-label={`Edit ${m.name}`}
|
||||||
@@ -1556,7 +1573,7 @@ export function CampaignHomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-lg card">
|
<div data-jump="Notes" className="mt-lg card">
|
||||||
<div className="card-title">Shared Notes</div>
|
<div className="card-title">Shared Notes</div>
|
||||||
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
|
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||||
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
import { NumberInput } from '../components/NumberInput';
|
import { NumberInput } from '../components/NumberInput';
|
||||||
@@ -95,6 +96,7 @@ export function CharacterBuilderPage() {
|
|||||||
const { characterId = '' } = useParams();
|
const { characterId = '' } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||||
|
useRememberCampaign(character?.campaignId);
|
||||||
const roll = useBuildRoll(characterId);
|
const roll = useBuildRoll(characterId);
|
||||||
const choose = useBuildChoose(characterId);
|
const choose = useBuildChoose(characterId);
|
||||||
const finalize = useBuildFinalize(characterId);
|
const finalize = useBuildFinalize(characterId);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { useAuth } from '../auth/AuthContext';
|
|||||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
import { NumberInput } from '../components/NumberInput';
|
import { NumberInput } from '../components/NumberInput';
|
||||||
|
import { JumpNav } from '../components/JumpNav';
|
||||||
|
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||||
import { HpBar } from '../components/HpBar';
|
import { HpBar } from '../components/HpBar';
|
||||||
import { DeathSaves } from '../components/DeathSaves';
|
import { DeathSaves } from '../components/DeathSaves';
|
||||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||||
@@ -235,6 +237,7 @@ export function CharacterSheetPage() {
|
|||||||
const { characterId = '' } = useParams();
|
const { characterId = '' } = useParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||||
|
useRememberCampaign(character?.campaignId);
|
||||||
const patchCharacter = usePatchCharacter(characterId);
|
const patchCharacter = usePatchCharacter(characterId);
|
||||||
useCampaignSocket(character?.campaignId);
|
useCampaignSocket(character?.campaignId);
|
||||||
|
|
||||||
@@ -552,7 +555,9 @@ export function CharacterSheetPage() {
|
|||||||
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
|
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card mt-md">
|
<JumpNav />
|
||||||
|
|
||||||
|
<div data-jump="Abilities" className="card mt-md">
|
||||||
<div className="card-title">Ability Scores</div>
|
<div className="card-title">Ability Scores</div>
|
||||||
<div className="grid grid-cols-3">
|
<div className="grid grid-cols-3">
|
||||||
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
|
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
|
||||||
@@ -568,7 +573,7 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 mt-md">
|
<div className="grid grid-cols-2 mt-md">
|
||||||
<div className="card">
|
<div data-jump="Saves" className="card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
Saving Throws <InfoTip term="SAVING_THROW" />
|
Saving Throws <InfoTip term="SAVING_THROW" />
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +586,7 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div data-jump="Skills" className="card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
Skills <InfoTip term="SKILL_BONUS" />
|
Skills <InfoTip term="SKILL_BONUS" />
|
||||||
</div>
|
</div>
|
||||||
@@ -615,7 +620,7 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card mt-md">
|
<div data-jump="Weapons" className="card mt-md">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
Weapons <InfoTip term="ATTACK_BONUS" />
|
Weapons <InfoTip term="ATTACK_BONUS" />
|
||||||
</div>
|
</div>
|
||||||
@@ -678,7 +683,7 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{campaignSpells && campaignSpells.length > 0 && (
|
{campaignSpells && campaignSpells.length > 0 && (
|
||||||
<div className="card mt-md">
|
<div data-jump="Spells" className="card mt-md">
|
||||||
<div className="card-title">Spells Known</div>
|
<div className="card-title">Spells Known</div>
|
||||||
{spellsPage.pageItems.map((cs) => (
|
{spellsPage.pageItems.map((cs) => (
|
||||||
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||||
@@ -742,7 +747,7 @@ export function CharacterSheetPage() {
|
|||||||
|
|
||||||
{!character.restricted && (
|
{!character.restricted && (
|
||||||
<>
|
<>
|
||||||
<div className="card mt-md">
|
<div data-jump="Inventory" className="card mt-md">
|
||||||
<div className="card-title">Inventory</div>
|
<div className="card-title">Inventory</div>
|
||||||
{itemsPage.pageItems.map((held) => (
|
{itemsPage.pageItems.map((held) => (
|
||||||
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||||
@@ -822,7 +827,7 @@ export function CharacterSheetPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card mt-md">
|
<div data-jump="Money" className="card mt-md">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Money</span>
|
<span>Money</span>
|
||||||
<span className="mono">{formatPurse(character)}</span>
|
<span className="mono">{formatPurse(character)}</span>
|
||||||
@@ -999,7 +1004,7 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card mt-md">
|
<div data-jump="Notes" className="card mt-md">
|
||||||
<div className="card-title">Notes</div>
|
<div className="card-title">Notes</div>
|
||||||
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
|
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { NumberInput } from '../components/NumberInput';
|
|||||||
import { HpBar } from '../components/HpBar';
|
import { HpBar } from '../components/HpBar';
|
||||||
import { DeathSaves } from '../components/DeathSaves';
|
import { DeathSaves } from '../components/DeathSaves';
|
||||||
import { TradePanel } from '../components/TradePanel';
|
import { TradePanel } from '../components/TradePanel';
|
||||||
|
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
|
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
|
||||||
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||||
import type {
|
import type {
|
||||||
@@ -835,6 +836,7 @@ export function EncounterPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||||
useCampaignSocket(encounter?.campaignId);
|
useCampaignSocket(encounter?.campaignId);
|
||||||
|
useRememberCampaign(encounter?.campaignId);
|
||||||
|
|
||||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||||
const isDM = campaignData?.campaign.role === 'DM';
|
const isDM = campaignData?.campaign.role === 'DM';
|
||||||
|
|||||||
@@ -67,16 +67,16 @@ export function PartyOverviewPage() {
|
|||||||
const isMine = c.userId === user?.id;
|
const isMine = c.userId === user?.id;
|
||||||
return (
|
return (
|
||||||
<tr key={c.id}>
|
<tr key={c.id}>
|
||||||
<td>
|
<td className="cell-title">
|
||||||
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
|
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
|
||||||
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
|
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
|
||||||
</Link>
|
</Link>
|
||||||
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
|
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||||
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||||
<td>{c.level}</td>
|
<td data-label="Level">{c.level}</td>
|
||||||
<td>
|
<td data-label="HP">
|
||||||
{c.maxHp != null ? (
|
{c.maxHp != null ? (
|
||||||
<div style={{ minWidth: 90 }}>
|
<div style={{ minWidth: 90 }}>
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
@@ -90,9 +90,9 @@ export function PartyOverviewPage() {
|
|||||||
'—'
|
'—'
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{c.armorClass ?? '—'}</td>
|
<td data-label="AC">{c.armorClass ?? '—'}</td>
|
||||||
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.103.2",
|
"@tanstack/react-query": "^5.103.2",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "D&D 5e campaign manager — monorepo root",
|
"description": "D&D 5e campaign manager — monorepo root",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
Reference in New Issue
Block a user