The palette already lived in CSS custom properties, so dark mode is a token-override block in the new theme.css, applied via data-theme on the document element. A moon/sun toggle sits top right in the navbar; an explicit choice persists in localStorage, otherwise the app follows the OS preference live. An inline head script applies the theme before first paint so a dark reload never flashes light. Token hygiene sweep alongside: literal colours that broke under a dark palette (timeline chip text, color-mix against white, calendar stripes and bands, warning banners, nav background) moved into ten new tokens with light-mode values unchanged. The flip clock keeps its fixed dark look by design; the map keeps standard light tiles in both themes.
256 lines
7.5 KiB
JavaScript
256 lines
7.5 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');
|
|
}
|
|
|
|
// ---------- Theme switcher (dark/light) ----------
|
|
// Resolution order: explicit localStorage choice, else OS preference
|
|
// (followed live via the matchMedia listener below until the user picks
|
|
// explicitly). The inline script in index.html's <head> applies the initial
|
|
// attribute before first paint; this just keeps it in sync afterwards.
|
|
const THEME_KEY = 'theme';
|
|
|
|
function getStoredTheme() {
|
|
try {
|
|
return localStorage.getItem(THEME_KEY);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setStoredTheme(theme) {
|
|
try {
|
|
localStorage.setItem(THEME_KEY, theme);
|
|
} catch (e) {
|
|
// localStorage may throw in privacy modes; the choice just won't persist.
|
|
}
|
|
}
|
|
|
|
function currentTheme() {
|
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
}
|
|
|
|
let themeToggleBtn = null;
|
|
|
|
function updateThemeToggle() {
|
|
if (!themeToggleBtn) return;
|
|
const dark = currentTheme() === 'dark';
|
|
themeToggleBtn.textContent = dark ? '☀️' : '🌙';
|
|
themeToggleBtn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme');
|
|
}
|
|
|
|
function onThemeToggle() {
|
|
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
|
applyTheme(next);
|
|
setStoredTheme(next);
|
|
updateThemeToggle();
|
|
}
|
|
|
|
function renderThemeToggle() {
|
|
themeToggleBtn = el('button', {
|
|
class: 'theme-toggle',
|
|
type: 'button',
|
|
title: 'Toggle theme',
|
|
onClick: onThemeToggle,
|
|
});
|
|
updateThemeToggle();
|
|
return themeToggleBtn;
|
|
}
|
|
|
|
// Module-scope, attached once, app-lifetime by design (no removal path
|
|
// needed — unlike per-view listeners, this isn't tied to any view's mount).
|
|
if (window.matchMedia) {
|
|
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
const onSchemeChange = (e) => {
|
|
if (getStoredTheme()) return; // an explicit choice always wins
|
|
applyTheme(e.matches ? 'dark' : 'light');
|
|
updateThemeToggle();
|
|
};
|
|
if (media.addEventListener) media.addEventListener('change', onSchemeChange);
|
|
else media.addListener(onSchemeChange); // older Safari
|
|
}
|
|
|
|
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' },
|
|
renderThemeToggle(),
|
|
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();
|