Split the trip page into tabs and move the header into the navbar
Three deep-linkable tabs (#/trip/:id/:tab): Trip (countdown, calendar, map, summary), Money (costs + expenses side by side), Checklist. Panels stay mounted; switching toggles visibility and updates the URL via history.replaceState so an open day editor and the live tickers are undisturbed. The Leaflet map revalidates its size on every trip-tab activation but only auto-fits once, when its container first gains real size - later visits keep the user's pan/zoom. The full-width trip-header card is gone: the navbar now carries a compact identity (back link, truncated name, date/days/currency) plus a single overflow menu holding members, join code, edit (now a centered modal) and delete. Dead header CSS removed; new styles live in tabs.css.
This commit is contained in:
+1
-19
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="./css/checklist.css" />
|
||||
<link rel="stylesheet" href="./css/expenses.css" />
|
||||
<link rel="stylesheet" href="./css/timeline.css" />
|
||||
<link rel="stylesheet" href="./css/tabs.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+16
-4
@@ -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.
|
||||
|
||||
+29
-6
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+264
-108
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user