diff --git a/docs/API.md b/docs/API.md index 150326c..ac8ba34 100644 --- a/docs/API.md +++ b/docs/API.md @@ -400,7 +400,26 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept ## Frontend contract notes -- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed. +- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id[/:tab]`) is the expected design, so no server-side fallback is needed. + +### Trip page tabs & compact nav header (frontend-only) + +The trip page is split into three tabs, and the old full-width trip-header card is REMOVED — its contents move into the top navbar so the page starts with actual content. + +**Navbar trip context** — `renderNav()` (app.js) always includes an empty `.nav-trip-slot` element between the brand and the user area; non-trip views leave it empty (it costs no space). On a trip page, `tripDetail` fills it after the trip loads with a compact identity block: + +- `←` back icon-link to `#/trips`, the trip name (truncated with ellipsis when long, `title` attr carries the full name), and a muted one-line subtitle `29 Sept – 6 Oct · 8 days · EUR` (hidden on narrow screens). +- A single `⋯` trip-menu button on the nav's right (before the user area) opening a small dropdown panel containing everything the old header card held: the members list (with the owner's `×` remove buttons), the join-code chip + 📋 Copy + ↻ Regenerate (owner only), `✎ Edit trip`, and `Delete trip` (owner only, danger-styled). The dropdown closes on outside click and Escape. +- `✎ Edit trip` opens the existing edit form (name/dates/currency) in a centered modal (`.overlay-center`/`.modal` pattern like the checklist-suggestions dialog) instead of expanding inside a header card. +- The slot is naturally discarded when the router re-renders the nav on navigation; `refreshTrip()` must re-fill it (member/join-code/name changes show immediately). + +- **Tabs**: `trip` (countdown, calendar, then map + summary in the existing `detail-grid`), `money` (Costs and Expenses cards side by side in a two-column `money-grid`, stacked on narrow screens), `checklist` (the checklist card, centered at a comfortable reading width). Tab bar order: Trip, Money, Checklist — icons + labels (🗓️ Trip / 💰 Money / 🧳 Checklist), rendered as the first element of the page (the navbar now carries the trip identity; there is no header card). +- **URL**: `#/trip/:id/:tab` with `tab ∈ trip|money|checklist`; missing or unknown tab segment → `trip` (so old `#/trip/5` links keep working). `parseHash` passes the extra segment through as `params.tab`; unknown segments must not 404. +- **Switching is NOT navigation**: clicking a tab toggles panel visibility and updates the address bar via `history.replaceState` — it must NOT set `location.hash` (a `hashchange` would close the day-editor overlay, stop the calendar's now-ticker, and trigger a full route/re-fetch). Deep-links work because the router reads the tab on load/route; the back button traverses pages, not tabs. +- **All three panels stay mounted** — switching toggles a class/`hidden` attr, never re-renders. `refreshTrip()` re-draws all panels but must preserve the active tab. `refreshCosts()` keeps its swap-in-place behaviour regardless of which tab is active. +- **Map sizing**: Leaflet initialised in a hidden panel has zero size; on every activation of the `trip` tab the map must be size-revalidated (an exposed `invalidateSize` hook on the map view, or an equivalent explicit mechanism — not a reliance on browser resize happening to occur). +- **Keyboard/a11y**: the tab bar uses `role="tablist"`/`role="tab"`/`aria-selected` and the panels `role="tabpanel"`; Left/Right arrows move between tabs. +- Tab, nav-slot, trip-menu-dropdown, and edit-modal styles live in `public/css/tabs.css` (new; styles.css is at its 500-line cap and must not grow). - Leaflet 1.9.x via unpkg CDN in `index.html`. - Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load. - Expenses UI lives in `public/js/views/expenses.js` (+ `public/css/expenses.css` — styles.css is at its 500-line cap), rendered as a card in the trip detail side column directly below Costs (above Checklist). Self-fetching from `GET .../expenses` (never triggers a whole-trip refresh; after add/edit/delete it re-fetches itself AND tells the Costs panel to refresh). Shows: trip total + per-day grouped rows (day heading with day total; each row = category icon, description, payer, amount) by default; a **sort control** (Date ↑/↓, Amount ↑/↓, Category, Payer — non-date sorts flatten to a single list, pure client-side); a quick-add row (date defaulting to today clamped into the trip range, description, amount, category select, payer, split — same split modes/participants UI pattern as `costForm.js`); edit + delete per row; and an **Export CSV** button that simply navigates to `GET .../expenses/export.csv` (cookie auth makes a plain link work). diff --git a/public/css/styles.css b/public/css/styles.css index 87aaefa..ba11abd 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -175,8 +175,6 @@ textarea.input { resize: vertical; } .trip-card-currency { margin-left: auto; font-weight: 700; font-size: 0.72rem; letter-spacing: 0.03em; color: var(--text-faint); } .join-form { display: flex; flex-direction: column; gap: 0.8rem; } -/* Join-code row on the trip header */ -.joincode-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); } .joincode-chip { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; letter-spacing: 0.08em; background: var(--brand-soft); color: var(--brand-dark); padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); user-select: all; } /* ---------- Trip list ---------- */ @@ -211,22 +209,9 @@ textarea.input { resize: vertical; } .empty-state h3 { color: var(--text); } .error-box { text-align: center; padding: 2rem; color: var(--danger); display: flex; flex-direction: column; gap: 0.8rem; align-items: center; } -/* ---------- Trip header ---------- */ -.trip-header { display: flex; flex-direction: column; gap: 1rem; } -.trip-header-top { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } -.back-link { font-size: 0.85rem; color: var(--brand); font-weight: 600; } -.back-link:hover { text-decoration: underline; } -.trip-title { margin-top: 0.3rem; } -.trip-subtitle { margin-top: 0.2rem; } -.trip-header-actions { display: flex; gap: 0.5rem; align-items: flex-start; } -.members-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); } -.members-label { font-size: 0.82rem; font-weight: 600; color: var(--text-muted); } -.member-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.6rem 0.25rem 0.3rem; font-size: 0.85rem; font-weight: 600; } -.member-owner { background: #fffbeb; border-color: #fde68a; } +/* ---------- Trip menu dropdown (nav-bar trip identity, see tabs.css) ---------- */ .member-remove { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 1rem; line-height: 1; padding: 0; } .member-remove:hover { color: var(--danger); } -.invite-form { display: flex; gap: 0.4rem; margin-left: auto; } -.invite-form .input { width: 180px; } .trip-edit { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 0.9rem; border-top: 1px solid var(--border); } /* ---------- Calendar ---------- */ @@ -484,9 +469,6 @@ textarea.input { resize: vertical; } .cal-chip .chip-text { display: none; } .cal-chip { justify-content: center; } .stat-grid { grid-template-columns: repeat(3, 1fr); } - .invite-form { margin-left: 0; width: 100%; } - .invite-form .input { flex: 1; width: auto; } - .trip-header-actions { width: 100%; } } @media (max-width: 480px) { .calendar { gap: 3px; } diff --git a/public/css/tabs.css b/public/css/tabs.css new file mode 100644 index 0000000..c810677 --- /dev/null +++ b/public/css/tabs.css @@ -0,0 +1,66 @@ +/* Trip page tabs + compact nav header. Split out of styles.css — which is at + its 500-line cap — following the same per-feature stylesheet convention as + flipclock.css/checklist.css/expenses.css. Reuses the shared palette and + existing vocabulary (.card, .btn, .icon-btn, .field, .form-row, + .form-actions, .form-error, .slideover-head, .overlay, .overlay-center, + .modal, .member-avatar, .member-remove, .role-badge, .joincode-chip) + rather than re-declaring it. */ + +/* ---------- Nav trip-identity slot ---------- */ +.nav-trip-slot { display: flex; align-items: center; gap: 0.7rem; flex: 1; min-width: 0; margin: 0 1rem; } +.nav-trip-slot:empty { display: none; margin: 0; } +.nav-trip-back { flex-shrink: 0; font-size: 1.1rem; font-weight: 700; color: var(--brand); } +.nav-trip-back:hover { text-decoration: underline; } +.nav-trip-id { display: flex; flex-direction: column; min-width: 0; flex: 1; line-height: 1.3; } +.nav-trip-name { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nav-trip-sub { font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.nav-trip-menu { position: relative; flex-shrink: 0; } +.nav-trip-menu-btn { font-size: 1.15rem; } + +.trip-menu-dropdown { + position: absolute; top: calc(100% + 0.5rem); right: 0; z-index: 30; + width: min(300px, 90vw); max-height: 70vh; overflow-y: auto; + display: none; flex-direction: column; gap: 0.7rem; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); padding: 0.85rem; animation: fade 0.12s ease; +} +.trip-menu-dropdown.open { display: flex; } +.trip-menu-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-bottom: 0.35rem; } +.trip-menu-members { display: flex; flex-direction: column; gap: 0.4rem; max-height: 200px; overflow-y: auto; } +.trip-menu-member { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; } +.trip-menu-member-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trip-menu-joincode { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; padding-top: 0.6rem; border-top: 1px solid var(--border); } +.trip-menu-actions { display: flex; flex-direction: column; gap: 0.4rem; padding-top: 0.6rem; border-top: 1px solid var(--border); } +.trip-menu-actions .btn { justify-content: flex-start; } + +/* Edit-trip modal reuses .trip-edit's field layout but not its old header-card + top border/spacing (it now opens directly under the modal's own heading). */ +.modal .trip-edit { padding-top: 0; border-top: none; } + +/* ---------- Tab bar ---------- */ +.tab-bar { display: inline-flex; gap: 0.3rem; padding: 0.3rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); } +.tab-btn { display: flex; align-items: center; gap: 0.4rem; border: none; background: none; padding: 0.5rem 0.9rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: 600; font-size: 0.9rem; color: var(--text-muted); } +.tab-btn:hover { color: var(--text); } +.tab-btn.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); } +.tab-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } +.tab-icon { font-size: 1rem; } +.tab-panel[hidden] { display: none; } + +/* ---------- Money tab (Costs + Expenses side by side) ---------- */ +.money-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; align-items: start; } + +/* ---------- Checklist tab (single card, comfortable reading width) ---------- */ +.checklist-wrap { max-width: 720px; margin: 0 auto; } + +@media (max-width: 900px) { + .money-grid { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .nav-trip-sub { display: none; } + .nav-trip-slot { gap: 0.4rem; margin: 0 0.5rem; } +} +@media (max-width: 480px) { + .tab-label { display: none; } + .tab-btn { padding: 0.5rem 0.65rem; } +} diff --git a/public/index.html b/public/index.html index 0c6564c..5a086f5 100644 --- a/public/index.html +++ b/public/index.html @@ -25,6 +25,7 @@ +
diff --git a/public/js/app.js b/public/js/app.js index 6529683..04a3bf3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -18,16 +18,22 @@ function navigate(hash) { } // Parse "#/trip/5" -> { name: 'trip', params: { id: '5' } } +// "#/trip/5/money" -> { name: 'trip', params: { id: '5', tab: 'money' } }; an +// unrecognized third segment is passed through as-is (tripDetail defaults it +// to 'trip' rather than the router 404ing on it). function parseHash() { const raw = (location.hash || '').replace(/^#/, ''); const parts = raw.split('/').filter(Boolean); if (parts.length === 0) return { name: 'trips', params: {} }; if (parts[0] === 'login') return { name: 'login', params: {} }; if (parts[0] === 'trips') return { name: 'trips', params: {} }; - if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1] } }; + if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1], tab: parts[2] } }; return { name: 'trips', params: {} }; } +// The empty .nav-trip-slot sits between the brand and the user area; non-trip +// views leave it empty (it costs no space — see .nav-trip-slot:empty in +// tabs.css). tripDetail fills it via ctx.navTripSlot once the trip loads. function renderNav() { const nav = el( 'header', @@ -38,6 +44,7 @@ function renderNav() { el('span', { class: 'brand-mark' }, '🧭'), el('span', { class: 'brand-name' }, 'Trip Plan'), ), + el('div', { class: 'nav-trip-slot' }), el( 'div', { class: 'nav-right' }, @@ -132,16 +139,21 @@ function render() { const container = root(); clear(container); - if (state.user) container.appendChild(renderNav()); + let navTripSlot = null; + if (state.user) { + const nav = renderNav(); + navTripSlot = nav.querySelector('.nav-trip-slot'); + container.appendChild(nav); + } const viewEl = el('main', { class: 'view', id: 'view' }); container.appendChild(viewEl); - const ctx = { state, navigate, refresh: render }; + const ctx = { state, navigate, refresh: render, navTripSlot }; if (view.name === 'login') renderAuth(viewEl, ctx); else if (view.name === 'trips') renderTrips(viewEl, ctx); - else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id); + else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id, view.params.tab); } // Exposed so views can update the current user after login/register. diff --git a/public/js/views/map.js b/public/js/views/map.js index 0136559..48c60d8 100644 --- a/public/js/views/map.js +++ b/public/js/views/map.js @@ -17,6 +17,13 @@ export function renderMap(tctx) { const route = tctx.route || { stops: [], legs: [], totalKm: 0 }; const stops = route.stops || []; + // Leaflet initialised while its tab panel is hidden gets a zero-size + // container; tripDetail calls this on every activation of the trip tab so + // the map (and its fitBounds zoom, computed against whatever size the + // container had at creation time) catches up. No-op until the map actually + // exists — overwritten at the end of initMap() below. + tctx.mapInvalidate = () => {}; + const section = el('section', { class: 'card map-section' }); section.appendChild( el( @@ -54,7 +61,7 @@ export function renderMap(tctx) { // Leaflet needs the container attached with a real size, so init on the // next tick after this section is mounted into the page. - setTimeout(() => initMap(mapDiv, stops, legs, kmEls), 0); + setTimeout(() => initMap(mapDiv, stops, legs, kmEls, tctx), 0); return section; } @@ -75,7 +82,7 @@ function visibleLegs(route, stops) { return result; } -function initMap(mapDiv, stops, legs, kmEls) { +function initMap(mapDiv, stops, legs, kmEls, tctx) { const L = window.L; if (!L) { mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.')); @@ -126,13 +133,29 @@ function initMap(mapDiv, stops, legs, kmEls) { return { leg, a, b, line, label, color }; }); - if (points.length === 1) { - map.setView(points[0], 10); - } else { - map.fitBounds(L.latLngBounds(points).pad(0.2)); + function fit() { + if (points.length === 1) map.setView(points[0], 10); + else map.fitBounds(L.latLngBounds(points).pad(0.2)); } + fit(); map.invalidateSize(); + // A container sized 0 at creation time (hidden tab panel) gives fitBounds a + // bogus zoom that invalidateSize() alone won't correct, so the first + // activation after the container actually gets a real size must re-fit. + // But re-fitting on EVERY activation would silently discard any pan/zoom + // the user did on a previous visit to the tab — so fit() only runs once, + // the first time the container transitions from zero-width to sized. + let fitted = mapDiv.offsetWidth > 0; + tctx.mapInvalidate = () => { + if (!mapDiv.isConnected) return; + map.invalidateSize(); + if (!fitted && mapDiv.offsetWidth > 0) { + fit(); + fitted = true; + } + }; + fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls); } diff --git a/public/js/views/tripDetail.js b/public/js/views/tripDetail.js index 89d3f95..c96e3d7 100644 --- a/public/js/views/tripDetail.js +++ b/public/js/views/tripDetail.js @@ -1,8 +1,11 @@ -// Orchestrates the trip page: header + calendar, then map + summary. -// Owns the shared trip context passed to the calendar, map, summary and -// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip, -// openDay }. After any mutation, refreshTrip() re-fetches the trip and its -// derived /route data so all three panels stay in sync. +// Orchestrates the trip page: a compact nav-bar trip identity block, a +// Trip/Money/Checklist tab bar, and three always-mounted panels. Owns the +// shared trip context passed to the calendar, map, summary, costs, expenses, +// checklist and day-editor sub-views: { state, navigate, navTripSlot, +// tripId, trip, route, costs, refreshTrip, refreshCosts, openDay, +// mapInvalidate }. After any mutation, refreshTrip() re-fetches the trip and +// its derived /route data so all panels (including the nav identity block) +// stay in sync. import { api } from '../api.js'; import { el, clear, mount, loading, errorBox, toast } from '../dom.js'; import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js'; @@ -15,7 +18,22 @@ import { renderChecklist } from './checklist.js'; import { renderCountdown } from './flipclock.js'; import { openDayEditor } from './dayEditor.js'; -export function renderTripDetail(container, ctx, id) { +const TABS = [ + { id: 'trip', icon: '🗓️', label: 'Trip' }, + { id: 'money', icon: '💰', label: 'Money' }, + { id: 'checklist', icon: '🧳', label: 'Checklist' }, +]; +const TAB_IDS = TABS.map((t) => t.id); + +// The nav trip-menu dropdown attaches document-level listeners (outside +// click, Escape, hashchange) while open. Only one trip page is ever mounted +// at a time, so — same convention as calendar.js's now-slider ticker — a +// module-level closer lets a fresh renderNavTripContext() tear down a +// still-open dropdown's listeners before it rebuilds the slot (refreshTrip +// re-draws the nav without navigating away, which would otherwise leak them). +let closeActiveDropdown = () => {}; + +export function renderTripDetail(container, ctx, id, tab) { const tctx = { ...ctx, tripId: id, @@ -25,10 +43,14 @@ export function renderTripDetail(container, ctx, id) { _onModalRefresh: null, }; + let activeTab = TAB_IDS.includes(tab) ? tab : 'trip'; + // The mounted Costs section node, so refreshCosts() can swap it in place // without redrawing the whole page (self-fetching cards like Expenses // trigger this instead of tctx.refreshTrip()). let costsSection = null; + let tabButtons = []; + let tabPanels = []; mount(container, loading('Loading trip…')); init(); @@ -89,95 +111,184 @@ export function renderTripDetail(container, ctx, id) { } function draw() { + renderNavTripContext(); + const page = el('div', { class: 'page' }); - page.appendChild(renderHeader()); - page.appendChild(renderCountdown(tctx)); - page.appendChild(renderCalendar(tctx)); - costsSection = renderCosts(tctx); - page.appendChild( + page.appendChild(renderTabBar()); + + const tripPanel = el('section', { + class: 'tab-panel', id: 'tab-panel-trip', role: 'tabpanel', 'aria-labelledby': 'tab-trip', + }); + tripPanel.appendChild(renderCountdown(tctx)); + tripPanel.appendChild(renderCalendar(tctx)); + tripPanel.appendChild( el( 'div', { class: 'detail-grid' }, renderMap(tctx), - el( - 'div', - { class: 'detail-side' }, - renderSummary(tctx), - costsSection, - renderExpenses(tctx), - renderChecklist(tctx), - ), + el('div', { class: 'detail-side' }, renderSummary(tctx)), ), ); + + costsSection = renderCosts(tctx); + const moneyPanel = el('section', { + class: 'tab-panel', id: 'tab-panel-money', role: 'tabpanel', 'aria-labelledby': 'tab-money', + }); + moneyPanel.appendChild(el('div', { class: 'money-grid' }, costsSection, renderExpenses(tctx))); + + const checklistPanel = el('section', { + class: 'tab-panel', id: 'tab-panel-checklist', role: 'tabpanel', 'aria-labelledby': 'tab-checklist', + }); + checklistPanel.appendChild(el('div', { class: 'checklist-wrap' }, renderChecklist(tctx))); + + tabPanels = [tripPanel, moneyPanel, checklistPanel]; + page.appendChild(tripPanel); + page.appendChild(moneyPanel); + page.appendChild(checklistPanel); + mount(container, page); + updateTabUI(); } - function renderHeader() { + // ---------- Tab bar ---------- + + function renderTabBar() { + tabButtons = TABS.map((t) => el( + 'button', + { + class: 'tab-btn', + type: 'button', + id: `tab-${t.id}`, + role: 'tab', + 'aria-controls': `tab-panel-${t.id}`, + 'aria-selected': 'false', + tabindex: '-1', + onClick: () => setActiveTab(t.id), + }, + el('span', { class: 'tab-icon' }, t.icon), + el('span', { class: 'tab-label' }, t.label), + )); + + const bar = el('div', { class: 'tab-bar', role: 'tablist', 'aria-label': 'Trip sections' }, ...tabButtons); + bar.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + const idx = TAB_IDS.indexOf(activeTab); + const dir = e.key === 'ArrowRight' ? 1 : -1; + const next = TABS[(idx + dir + TABS.length) % TABS.length]; + setActiveTab(next.id); + document.getElementById(`tab-${next.id}`).focus(); + }); + return bar; + } + + // Switching is NOT navigation: toggles panel visibility and updates the + // address bar via history.replaceState — never location.hash (a + // hashchange would close the day-editor overlay and stop the calendar's + // now-ticker / countdown, see docs/API.md). + function setActiveTab(nextTab) { + activeTab = TAB_IDS.includes(nextTab) ? nextTab : 'trip'; + updateTabUI(); + history.replaceState(null, '', `#/trip/${tctx.tripId}/${activeTab}`); + if (activeTab === 'trip' && typeof tctx.mapInvalidate === 'function') tctx.mapInvalidate(); + } + + function updateTabUI() { + for (const btn of tabButtons) { + const isActive = btn.id === `tab-${activeTab}`; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-selected', String(isActive)); + btn.tabIndex = isActive ? 0 : -1; + } + for (const panel of tabPanels) { + if (panel.id === `tab-panel-${activeTab}`) panel.removeAttribute('hidden'); + else panel.setAttribute('hidden', ''); + } + } + + // ---------- Nav trip-identity block ---------- + + function renderNavTripContext() { + closeActiveDropdown(); + const slot = tctx.navTripSlot; + if (!slot) return; const { trip, members } = tctx.trip; const isOwner = trip.owner_id === tctx.state.user.id; const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date); - const header = el('div', { class: 'trip-header card' }); + const menuBtn = el('button', { + class: 'icon-btn nav-trip-menu-btn', type: 'button', title: 'Trip menu', + 'aria-haspopup': 'true', 'aria-expanded': 'false', + }, '⋯'); - const titleRow = el( - 'div', - { class: 'trip-header-top' }, + let outsideHandler = null; + let escHandler = null; + let hashHandler = null; + + function closeMenu() { + dropdown.classList.remove('open'); + menuBtn.setAttribute('aria-expanded', 'false'); + if (outsideHandler) { document.removeEventListener('click', outsideHandler); outsideHandler = null; } + if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; } + if (hashHandler) { window.removeEventListener('hashchange', hashHandler); hashHandler = null; } + } + function openMenu() { + dropdown.classList.add('open'); + menuBtn.setAttribute('aria-expanded', 'true'); + outsideHandler = (e) => { if (!dropdown.contains(e.target) && e.target !== menuBtn) closeMenu(); }; + escHandler = (e) => { if (e.key === 'Escape') closeMenu(); }; + hashHandler = closeMenu; + document.addEventListener('click', outsideHandler); + document.addEventListener('keydown', escHandler); + window.addEventListener('hashchange', hashHandler); + } + + const dropdown = buildDropdown(trip, members, isOwner, closeMenu); + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (dropdown.classList.contains('open')) closeMenu(); else openMenu(); + }); + closeActiveDropdown = closeMenu; + + mount( + slot, + el('a', { class: 'nav-trip-back', href: '#/trips', title: 'All trips', 'aria-label': 'Back to all trips' }, '←'), el( 'div', - {}, - el( - 'a', - { class: 'back-link', href: '#/trips' }, - '← All trips', - ), - el('h1', { class: 'trip-title' }, trip.name), - el( - 'p', - { class: 'trip-subtitle muted' }, - '📅 ', formatRange(trip.start_date, trip.end_date), - ' · ', pluralize(dayCount, 'day', 'days'), - ' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'), - ), - ), - el( - 'div', - { class: 'trip-header-actions' }, - el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'), - isOwner - ? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete') - : null, - ), - ); - - const membersRow = el( - 'div', - { class: 'members-row' }, - el('span', { class: 'members-label' }, 'Members:'), - ...members.map((m) => + { class: 'nav-trip-id' }, + el('span', { class: 'nav-trip-name', title: trip.name }, trip.name), el( 'span', - { class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` }, - el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()), - m.display_name, - isOwner && m.role !== 'owner' - ? el('button', { - class: 'member-remove', - title: `Remove ${m.display_name}`, - onClick: () => onRemoveMember(m), - }, '×') - : null, + { class: 'nav-trip-sub muted' }, + formatRange(trip.start_date, trip.end_date), + ' · ', pluralize(dayCount, 'day', 'days'), + ' · ', trip.currency || 'USD', ), ), + el('div', { class: 'nav-trip-menu' }, menuBtn, dropdown), ); - - header.appendChild(titleRow); - header.appendChild(joinCodeRow(trip, isOwner)); - header.appendChild(membersRow); - return header; } - // Share code for inviting others — display only, not a credential. - function joinCodeRow(trip, isOwner) { + function buildDropdown(trip, members, isOwner, closeMenu) { + const membersList = el( + 'div', + { class: 'trip-menu-members' }, + el('div', { class: 'trip-menu-label' }, 'Members'), + ...members.map((m) => el( + 'div', + { class: 'trip-menu-member' }, + el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()), + el('span', { class: 'trip-menu-member-name' }, m.display_name), + m.role === 'owner' ? el('span', { class: 'role-badge role-owner' }, 'Owner') : null, + isOwner && m.role !== 'owner' + ? el('button', { + class: 'member-remove', type: 'button', title: `Remove ${m.display_name}`, + onClick: () => { closeMenu(); onRemoveMember(m); }, + }, '×') + : null, + )), + ); + const grouped = groupCode(trip.join_code, 4); const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy'); copyBtn.addEventListener('click', async () => { @@ -188,43 +299,65 @@ export function renderTripDetail(container, ctx, id) { toast('Copy failed — select and copy it manually'); } }); - - async function onRegenerate() { - if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return; - try { - await api.trips.regenerateJoinCode(tctx.tripId); - toast('Join code regenerated', 'success'); - await tctx.refreshTrip(); - } catch (err) { - toast(err.message); - } - } - - return el( + const joinRow = el( 'div', - { class: 'joincode-row' }, - el('span', { class: 'members-label' }, 'Join code:'), + { class: 'trip-menu-joincode' }, el('span', { class: 'joincode-chip' }, grouped), copyBtn, isOwner - ? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate') + ? el('button', { + class: 'btn btn-sm btn-ghost', type: 'button', + onClick: () => { closeMenu(); onRegenerate(); }, + }, '↻ Regenerate') : null, ); + + const actions = el( + 'div', + { class: 'trip-menu-actions' }, + el('button', { + class: 'btn btn-sm', type: 'button', onClick: () => { closeMenu(); openEditModal(); }, + }, '✎ Edit trip'), + isOwner + ? el('button', { + class: 'btn btn-sm btn-danger-ghost', type: 'button', + onClick: () => { closeMenu(); onDelete(); }, + }, 'Delete trip') + : null, + ); + + return el('div', { class: 'trip-menu-dropdown', role: 'menu' }, membersList, joinRow, actions); } - function toggleEdit(header) { - const existing = header.querySelector('.trip-edit'); - if (existing) { - existing.remove(); - return; - } + // ---------- Edit-trip modal ---------- + + function openEditModal() { const { trip } = tctx.trip; + const overlay = el('div', { class: 'overlay overlay-center' }); + const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true' }); + overlay.appendChild(modal); + document.body.appendChild(overlay); + document.body.classList.add('no-scroll'); + + function close() { + document.body.classList.remove('no-scroll'); + overlay.remove(); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name }); const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date }); const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date }); - const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code' }); + const currencyInput = el('input', { + class: 'input input-currency', type: 'text', maxlength: '3', + value: trip.currency || 'USD', 'aria-label': 'Currency code', + }); const errorEl = el('p', { class: 'form-error' }); const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes'); + const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button', onClick: close }, 'Cancel'); async function onSubmit(e) { e.preventDefault(); @@ -241,6 +374,7 @@ export function renderTripDetail(container, ctx, id) { try { await api.trips.update(tctx.tripId, { name, start_date, end_date, currency }); toast('Trip updated', 'success'); + close(); await tctx.refreshTrip(); } catch (err) { errorEl.textContent = err.message; @@ -249,28 +383,39 @@ export function renderTripDetail(container, ctx, id) { } } - const editBox = el( - 'form', - { class: 'trip-edit', onSubmit }, + mount( + modal, el( 'div', - { class: 'form-row' }, - el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput), + { class: 'slideover-head' }, + el('h2', {}, '✎ Edit trip'), + el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'), ), el( - 'div', - { class: 'form-row' }, - el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput), - el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput), - el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput), + 'form', + { class: 'trip-edit', onSubmit }, + el( + 'div', + { class: 'form-row' }, + el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput), + ), + el( + 'div', + { class: 'form-row' }, + el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput), + el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput), + el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput), + ), + el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'), + errorEl, + el('div', { class: 'form-actions' }, cancelBtn, saveBtn), ), - el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'), - errorEl, - el('div', { class: 'form-actions' }, saveBtn), ); - header.appendChild(editBox); + nameInput.focus(); } + // ---------- Member / trip mutations ---------- + async function onRemoveMember(member) { if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return; try { @@ -282,6 +427,17 @@ export function renderTripDetail(container, ctx, id) { } } + async function onRegenerate() { + if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return; + try { + await api.trips.regenerateJoinCode(tctx.tripId); + toast('Join code regenerated', 'success'); + await tctx.refreshTrip(); + } catch (err) { + toast(err.message); + } + } + async function onDelete() { const { trip } = tctx.trip; if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;