From 2dcb41ae4a00fcd83084cc3ff47ca125b92fb369 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Wed, 7 Oct 2026 14:37:31 +0200 Subject: [PATCH] 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 --- README.md | 21 +- backend/package.json | 2 +- docs/CHARACTERS.md | 6 +- docs/ENCOUNTERS.md | 6 +- frontend/package.json | 2 +- frontend/src/components/JumpNav.tsx | 36 ++ frontend/src/components/Layout.tsx | 49 ++- frontend/src/index.css | 423 +++++++++++++++++++- frontend/src/lib/lastCampaign.ts | 34 ++ frontend/src/pages/CampaignHomePage.tsx | 95 +++-- frontend/src/pages/CharacterBuilderPage.tsx | 2 + frontend/src/pages/CharacterSheetPage.tsx | 21 +- frontend/src/pages/EncounterPage.tsx | 2 + frontend/src/pages/PartyOverviewPage.tsx | 16 +- package-lock.json | 8 +- package.json | 2 +- 16 files changed, 646 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/JumpNav.tsx create mode 100644 frontend/src/lib/lastCampaign.ts diff --git a/README.md b/README.md index 81ca19d..878ac86 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/package.json b/backend/package.json index 8b74b01..d9ca5a0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.10.0", + "version": "1.10.1", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index ea2ca61..209c518 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -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 diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index e3564fd..417dcc1 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 73703db..82468ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.10.0", + "version": "1.10.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/JumpNav.tsx b/frontend/src/components/JumpNav.tsx new file mode 100644 index 0000000..20f5f04 --- /dev/null +++ b/frontend/src/components/JumpNav.tsx @@ -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([]); + + // Sections come and go as data loads, so the page is watched for them. + useEffect(() => { + const look = () => { + const found = [...document.querySelectorAll('[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 ( + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 66e41cd..ba17a38 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) => ( + `${className}${isActive ? ' is-active' : ''}`}> + + {d.icon} + + {d.label} + + )); + return (
@@ -14,22 +43,26 @@ export function Layout() { The Sunken Reach - + )}
+ {user && ( + + )}
); diff --git a/frontend/src/index.css b/frontend/src/index.css index 23d49f3..88cb4e8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 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; + } +} diff --git a/frontend/src/lib/lastCampaign.ts b/frontend/src/lib/lastCampaign.ts new file mode 100644 index 0000000..7f25ee0 --- /dev/null +++ b/frontend/src/lib/lastCampaign.ts @@ -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]); +} diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 213836b..3753daa 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -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 ( <> - + onToggleStarting(e.target.checked)} /> - +