Files
trip-plan/public/js/app.js
T
grabowski b361fb2687 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.
2026-08-30 13:31:32 +02:00

184 lines
5.4 KiB
JavaScript

// App bootstrap: auth check, top nav, and a tiny hash router.
// Routes: #/login, #/trips, #/trip/:id
import { api } from './api.js';
import { el, clear, mount, toast, loading } from './dom.js';
import { renderAuth } from './views/auth.js';
import { renderTrips } from './views/trips.js';
import { renderTripDetail } from './views/tripDetail.js';
const state = { user: null };
function root() {
return document.getElementById('app');
}
function navigate(hash) {
if (location.hash === hash) route();
else location.hash = 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], 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',
{ class: 'topnav' },
el(
'a',
{ class: 'brand', href: '#/trips' },
el('span', { class: 'brand-mark' }, '🧭'),
el('span', { class: 'brand-name' }, 'Trip Plan'),
),
el('div', { class: 'nav-trip-slot' }),
el(
'div',
{ class: 'nav-right' },
state.user ? renderUserArea() : null,
state.user ? el('button', { class: 'btn btn-ghost', onClick: onLogout }, 'Log out') : null,
),
);
return nav;
}
// Current user's display name with an inline pencil-edit (PATCH /api/auth/me).
function renderUserArea() {
const area = el('span', { class: 'nav-user' });
function initial() {
return (state.user.display_name || '?').charAt(0).toUpperCase();
}
function showDisplay() {
mount(
area,
el('span', { class: 'nav-avatar' }, initial()),
el('span', { class: 'nav-name' }, state.user.display_name || 'me'),
el('button', { class: 'nav-edit', title: 'Edit name', type: 'button', onClick: showEdit }, '✎'),
);
}
function showEdit() {
const input = el('input', {
class: 'input input-sm nav-name-input',
type: 'text',
maxlength: '40',
value: state.user.display_name || '',
'aria-label': 'Display name',
});
async function save() {
const dn = input.value.trim();
if (!dn) return showDisplay();
try {
const data = await api.auth.updateMe({ display_name: dn });
state.user = data.user;
showDisplay();
} catch (err) {
toast(err.message);
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
else if (e.key === 'Escape') showDisplay();
});
mount(
area,
input,
el('button', { class: 'btn btn-sm btn-primary', type: 'button', onClick: save }, 'Save'),
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: showDisplay }, 'Cancel'),
);
input.focus();
input.select();
}
showDisplay();
return area;
}
async function onLogout() {
try {
await api.auth.logout();
} catch (e) {
// Even if the request fails, drop local state.
}
state.user = null;
navigate('#/login');
}
function render() {
const view = parseHash();
// Defensively remove any body-level overlay (e.g. an open day editor) so it
// can never orphan on top of a freshly rendered view.
document.querySelectorAll('.overlay').forEach((n) => n.remove());
document.body.classList.remove('no-scroll');
// Auth guards.
if (!state.user && view.name !== 'login') {
navigate('#/login');
return;
}
if (state.user && view.name === 'login') {
navigate('#/trips');
return;
}
const container = root();
clear(container);
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, 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, view.params.tab);
}
// Exposed so views can update the current user after login/register.
function route() {
render();
}
async function bootstrap() {
const container = root();
clear(container);
container.appendChild(loading('Starting Trip Plan…'));
try {
const data = await api.auth.me();
state.user = data && data.user ? data.user : null;
} catch (e) {
state.user = null; // 401 is expected when logged out.
}
window.addEventListener('hashchange', route);
// render()'s guards redirect a signed-out visitor to #/login and a
// signed-in one away from it, so a single render() is enough here.
render();
}
bootstrap();