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
|
||||
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
|
||||
|
||||
- **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
|
||||
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
|
||||
(click your name in the top bar).
|
||||
("Account" in the navigation).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"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. |
|
||||
| Character sheet | Everything in this document. |
|
||||
| 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). |
|
||||
|
||||
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)
|
||||
- Weapon proficiency (everyone is proficient with everything)
|
||||
- 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
|
||||
sheet)
|
||||
bottom left of every page, or the last button of the bottom bar on a phone,
|
||||
is a plain roller that knows nothing about the sheet)
|
||||
- Conditions on the sheet, and resting
|
||||
- Automatic levelling from XP
|
||||
- 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
|
||||
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
|
||||
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
|
||||
@@ -442,7 +446,7 @@ People outside the campaign cannot open its encounters.
|
||||
## 13. What is not modelled
|
||||
|
||||
- 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
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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 { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { DiceRoller } from './DiceRoller';
|
||||
|
||||
export function Layout() {
|
||||
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 (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -14,22 +43,26 @@ export function Layout() {
|
||||
</span>
|
||||
The Sunken Reach
|
||||
</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">
|
||||
<Link to="/account" title="Account settings">
|
||||
{user.displayName}
|
||||
</Link>
|
||||
<span className="topbar-name">{user.displayName}</span>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
{user && (
|
||||
<nav className="bottom-nav" aria-label="Main">
|
||||
{links('bottom-nav-link')}
|
||||
</nav>
|
||||
)}
|
||||
<DiceRoller />
|
||||
</div>
|
||||
);
|
||||
|
||||
+419
-4
@@ -1,4 +1,6 @@
|
||||
:root {
|
||||
/* Dark throughout, so the browser draws its own controls dark too. */
|
||||
color-scheme: dark;
|
||||
--bg: #131009;
|
||||
--bg-elevated: #1b160e;
|
||||
--bg-card: #221b12;
|
||||
@@ -347,10 +349,8 @@ textarea {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='number'],
|
||||
/* Every text-like field, including an <input> with no type given. */
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-inset);
|
||||
@@ -1290,3 +1290,418 @@ button.death-pip {
|
||||
margin: 0;
|
||||
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 { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { JumpNav } from '../components/JumpNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { WaresEditor } from '../components/Wares';
|
||||
@@ -134,7 +135,7 @@ function PoolRowControls({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td>
|
||||
<td data-label="Starting option">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${entry.name} is a starting option`}
|
||||
@@ -142,7 +143,7 @@ function PoolRowControls({
|
||||
onChange={(e) => onToggleStarting(e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
@@ -570,6 +571,8 @@ export function CampaignHomePage() {
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
||||
const sharedNotesPage = usePagination(sharedNotes, 6);
|
||||
@@ -676,8 +679,10 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JumpNav />
|
||||
|
||||
{isDM && (
|
||||
<div className="card">
|
||||
<div data-jump="Settings" className="card">
|
||||
<div className="card-title">
|
||||
<span>Campaign Settings</span>
|
||||
<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>}
|
||||
|
||||
{!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>
|
||||
{myCharacters.map((c) => (
|
||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||
@@ -818,7 +823,7 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-lg">
|
||||
<div data-jump={isDM ? 'Characters' : 'Party'} className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
</div>
|
||||
@@ -846,7 +851,7 @@ export function CampaignHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-lg">
|
||||
<div data-jump="Encounters" className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{liveEncounters.length > 1 && (
|
||||
@@ -888,13 +893,17 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
|
||||
<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>
|
||||
)}
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<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">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
@@ -939,7 +948,9 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Pagination page={encountersPage.page} pageCount={encountersPage.pageCount} onPrev={encountersPage.prevPage} onNext={encountersPage.nextPage} />
|
||||
|
||||
{isDM && archivedEncounters.length > 0 && (
|
||||
<div className="mt-md">
|
||||
@@ -952,7 +963,7 @@ export function CampaignHomePage() {
|
||||
{showArchivedEncounters && (
|
||||
<>
|
||||
<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">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
@@ -979,6 +990,12 @@ export function CampaignHomePage() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Pagination
|
||||
page={archivedEncountersPage.page}
|
||||
pageCount={archivedEncountersPage.pageCount}
|
||||
onPrev={archivedEncountersPage.prevPage}
|
||||
onNext={archivedEncountersPage.nextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -986,7 +1003,7 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -998,7 +1015,7 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="NPCs" className="mt-lg card">
|
||||
<div className="card-title">
|
||||
<span>NPCs</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
|
||||
@@ -1100,7 +1117,7 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Locations" className="mt-lg card">
|
||||
<div className="card-title">
|
||||
<span>Locations</span>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-sm card">
|
||||
<div data-jump="Weapons" className="mt-sm card">
|
||||
<div className="card-title">
|
||||
<span>Weapons Pool</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -1218,12 +1235,12 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{weaponsPage.pageItems.map((w) => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td>{w.damageDice} {w.damageType}</td>
|
||||
<td>
|
||||
<td className="cell-title">{w.name}</td>
|
||||
<td data-label="Base damage">{w.damageDice} {w.damageType}</td>
|
||||
<td data-label="Level">
|
||||
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Growth">
|
||||
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
|
||||
</td>
|
||||
<PoolRowControls
|
||||
@@ -1239,7 +1256,7 @@ export function CampaignHomePage() {
|
||||
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Items" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Items Pool</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
|
||||
@@ -1294,13 +1311,13 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{itemsPage.pageItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.description}</td>
|
||||
<td>{item.damageDice || '—'}</td>
|
||||
<td>
|
||||
<td className="cell-title">{item.name}</td>
|
||||
<td className="cell-wide">{item.description}</td>
|
||||
<td data-label="Base dice">{item.damageDice || '—'}</td>
|
||||
<td data-label="Level">
|
||||
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Growth">
|
||||
{item.damageDice ? (
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Spells" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Spell List</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -1405,15 +1422,15 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{spellsPage.pageItems.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<strong>{s.name}</strong>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||
<td>
|
||||
<td data-label="Level" style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||
<td data-label="Damage">
|
||||
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Revives">
|
||||
<input
|
||||
type="checkbox"
|
||||
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} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Monsters" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Monsters</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -1507,7 +1524,7 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{monstersPage.pageItems.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<strong>{m.name}</strong>
|
||||
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
|
||||
{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>}
|
||||
</td>
|
||||
<td>{m.challengeRating || '—'}</td>
|
||||
<td>{m.ac}</td>
|
||||
<td>{m.maxHp}</td>
|
||||
<td>{m.speed}</td>
|
||||
<td>{fmtMod(m.attackBonus)}</td>
|
||||
<td>{m.damageDice || '—'}</td>
|
||||
<td>
|
||||
<td data-label="CR">{m.challengeRating || '—'}</td>
|
||||
<td data-label="AC">{m.ac}</td>
|
||||
<td data-label="HP">{m.maxHp}</td>
|
||||
<td data-label="Speed">{m.speed}</td>
|
||||
<td data-label="Attack">{fmtMod(m.attackBonus)}</td>
|
||||
<td data-label="Damage">{m.damageDice || '—'}</td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
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>
|
||||
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
|
||||
<textarea
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
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 { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
@@ -95,6 +96,7 @@ export function CharacterBuilderPage() {
|
||||
const { characterId = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const roll = useBuildRoll(characterId);
|
||||
const choose = useBuildChoose(characterId);
|
||||
const finalize = useBuildFinalize(characterId);
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { JumpNav } from '../components/JumpNav';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||
@@ -235,6 +237,7 @@ export function CharacterSheetPage() {
|
||||
const { characterId = '' } = useParams();
|
||||
const { user } = useAuth();
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const patchCharacter = usePatchCharacter(characterId);
|
||||
useCampaignSocket(character?.campaignId);
|
||||
|
||||
@@ -552,7 +555,9 @@ export function CharacterSheetPage() {
|
||||
<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="grid grid-cols-3">
|
||||
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
|
||||
@@ -568,7 +573,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 mt-md">
|
||||
<div className="card">
|
||||
<div data-jump="Saves" className="card">
|
||||
<div className="card-title">
|
||||
Saving Throws <InfoTip term="SAVING_THROW" />
|
||||
</div>
|
||||
@@ -581,7 +586,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div data-jump="Skills" className="card">
|
||||
<div className="card-title">
|
||||
Skills <InfoTip term="SKILL_BONUS" />
|
||||
</div>
|
||||
@@ -615,7 +620,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Weapons" className="card mt-md">
|
||||
<div className="card-title">
|
||||
Weapons <InfoTip term="ATTACK_BONUS" />
|
||||
</div>
|
||||
@@ -678,7 +683,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
{campaignSpells && campaignSpells.length > 0 && (
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Spells" className="card mt-md">
|
||||
<div className="card-title">Spells Known</div>
|
||||
{spellsPage.pageItems.map((cs) => (
|
||||
<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 && (
|
||||
<>
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Inventory" className="card mt-md">
|
||||
<div className="card-title">Inventory</div>
|
||||
{itemsPage.pageItems.map((held) => (
|
||||
<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 className="card mt-md">
|
||||
<div data-jump="Money" className="card mt-md">
|
||||
<div className="card-title">
|
||||
<span>Money</span>
|
||||
<span className="mono">{formatPurse(character)}</span>
|
||||
@@ -999,7 +1004,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Notes" className="card mt-md">
|
||||
<div className="card-title">Notes</div>
|
||||
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
|
||||
<textarea
|
||||
|
||||
@@ -31,6 +31,7 @@ import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
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 { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type {
|
||||
@@ -835,6 +836,7 @@ export function EncounterPage() {
|
||||
const { user } = useAuth();
|
||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||
useCampaignSocket(encounter?.campaignId);
|
||||
useRememberCampaign(encounter?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
|
||||
@@ -67,16 +67,16 @@ export function PartyOverviewPage() {
|
||||
const isMine = c.userId === user?.id;
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
|
||||
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
|
||||
</Link>
|
||||
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
|
||||
</td>
|
||||
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td>{c.level}</td>
|
||||
<td>
|
||||
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">{c.level}</td>
|
||||
<td data-label="HP">
|
||||
{c.maxHp != null ? (
|
||||
<div style={{ minWidth: 90 }}>
|
||||
<span className="mono">
|
||||
@@ -90,9 +90,9 @@ export function PartyOverviewPage() {
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{c.armorClass ?? '—'}</td>
|
||||
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
<td data-label="AC">{c.armorClass ?? '—'}</td>
|
||||
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user