Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// 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' } }
|
||||
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] } };
|
||||
return { name: 'trips', params: {} };
|
||||
}
|
||||
|
||||
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-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);
|
||||
if (state.user) container.appendChild(renderNav());
|
||||
|
||||
const viewEl = el('main', { class: 'view', id: 'view' });
|
||||
container.appendChild(viewEl);
|
||||
|
||||
const ctx = { state, navigate, refresh: render };
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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();
|
||||
Reference in New Issue
Block a user