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,91 @@
|
||||
// Tiny DOM helpers — no framework, just ergonomic element creation.
|
||||
|
||||
// el('div', { class: 'x', onClick: fn }, child, child, ...)
|
||||
// Attrs: class, dataset(obj), style(obj), html(innerHTML), on<Event>(fn),
|
||||
// boolean true -> present attribute, anything else -> setAttribute.
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, val] of Object.entries(attrs || {})) {
|
||||
if (val == null || val === false) continue;
|
||||
if (key === 'class') node.className = val;
|
||||
else if (key === 'dataset') Object.assign(node.dataset, val);
|
||||
else if (key === 'style' && typeof val === 'object') {
|
||||
// Custom properties (--x) must go through setProperty; plain assignment
|
||||
// silently drops them.
|
||||
for (const [prop, pv] of Object.entries(val)) {
|
||||
if (pv == null) continue;
|
||||
if (prop.startsWith('--')) node.style.setProperty(prop, pv);
|
||||
else node.style[prop] = pv;
|
||||
}
|
||||
}
|
||||
else if (key === 'html') node.innerHTML = val;
|
||||
else if (key === 'value') node.value = val;
|
||||
else if (key.startsWith('on') && typeof val === 'function')
|
||||
node.addEventListener(key.slice(2).toLowerCase(), val);
|
||||
else if (val === true) node.setAttribute(key, '');
|
||||
else node.setAttribute(key, val);
|
||||
}
|
||||
appendAll(node, children);
|
||||
return node;
|
||||
}
|
||||
|
||||
function appendAll(node, children) {
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child == null || child === false || child === true) continue;
|
||||
node.appendChild(
|
||||
typeof child === 'string' || typeof child === 'number'
|
||||
? document.createTextNode(String(child))
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function mount(container, ...children) {
|
||||
clear(container);
|
||||
appendAll(container, children);
|
||||
return container;
|
||||
}
|
||||
|
||||
export function loading(text = 'Loading…') {
|
||||
return el('div', { class: 'loading' }, el('span', { class: 'spinner' }), text);
|
||||
}
|
||||
|
||||
export function errorBox(message, onRetry) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'error-box' },
|
||||
el('p', {}, message || 'Something went wrong.'),
|
||||
onRetry ? el('button', { class: 'btn', onClick: onRetry }, 'Retry') : null,
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyState(title, subtitle, action) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'empty-state' },
|
||||
el('div', { class: 'empty-icon' }, '🧭'),
|
||||
el('h3', {}, title),
|
||||
subtitle ? el('p', {}, subtitle) : null,
|
||||
action || null,
|
||||
);
|
||||
}
|
||||
|
||||
let toastHost = null;
|
||||
export function toast(message, type = 'error', ms = 4200) {
|
||||
if (!toastHost) {
|
||||
toastHost = el('div', { class: 'toast-host' });
|
||||
document.body.appendChild(toastHost);
|
||||
}
|
||||
const node = el('div', { class: `toast toast-${type}` }, message);
|
||||
toastHost.appendChild(node);
|
||||
requestAnimationFrame(() => node.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
node.classList.remove('show');
|
||||
setTimeout(() => node.remove(), 300);
|
||||
}, ms);
|
||||
}
|
||||
Reference in New Issue
Block a user