// frontend/app.js /* Waymark — all URLs relative, resolved against the injected . Never use a leading slash: it would escape BASE_PATH. */ 'use strict'; // Swap this for a paid provider without rebuilding the binary. // Tile source comes from GET /api/config (TILE_URL in .env) so it can be // changed without rebuilding. These are only the fallback if that call fails. const TILE_FALLBACK = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const TILE_ATTRIB = '© OpenStreetMap contributors'; let tileUrl = TILE_FALLBACK; async function loadClientConfig() { try { const res = await fetch('api/config', { headers: { Accept: 'application/json' } }); if (res.ok) { const cfg = await res.json(); if (cfg.tile_url) tileUrl = cfg.tile_url; } } catch (err) { console.warn('using fallback tile source', err); } } const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B']; const DEFAULT_COLOUR = COLOURS[0]; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const state = { me: null, markers: [], layers: new Map(), // marker id -> leaflet layer map: null, watchId: null, hereLayer: null, gpxLayer: null, localGpx: null, // { name, text } awaiting an optional upload editing: null, // marker being edited, or null for a new one draftLatLng: null, colour: DEFAULT_COLOUR, }; /* ───────────────────────────── api ───────────────────────────── */ async function api(path, options = {}) { const res = await fetch(path, { credentials: 'same-origin', headers: options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }, ...options, }); if (res.status === 204) return null; let payload = null; try { payload = await res.json(); } catch { /* empty body */ } if (!res.ok) { const err = new Error((payload && payload.error) || 'Something went wrong.'); err.status = res.status; throw err; } return payload; } const json = (body) => ({ body: JSON.stringify(body) }); /* ───────────────────────── small helpers ───────────────────────── */ function note(form, message, kind) { const el = $('[data-note]', form); if (!el) return; el.textContent = message || ''; el.className = 'note' + (kind ? ' ' + kind : ''); } let toastTimer; function toast(message, bad) { const el = $('#toast'); el.textContent = message; el.className = 'toast' + (bad ? ' bad' : ''); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 3600); } async function submitting(form, fn) { const button = $('button[type=submit]', form); if (button) button.disabled = true; try { await fn(); } finally { if (button) button.disabled = false; } } const fmtCoord = (lat, lon) => `${lat.toFixed(5)}, ${lon.toFixed(5)}`; function fmtSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(); } /* ───────────────────────────── auth ───────────────────────────── */ function showPane(name) { $$('.pane').forEach((p) => { p.hidden = p.dataset.pane !== name; }); const subs = { login: 'Places worth going back to.', register: 'You will need the registration token.', forgot: 'We will email a link if that address is registered.', reset: 'Choose a new password.', }; $('#auth-sub').textContent = subs[name] || ''; } $$('[data-goto]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); showPane(link.dataset.goto); }); }); $('#form-login').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); try { state.me = await api('api/login', { method: 'POST', ...json(data) }); form.reset(); note(form, ''); enterApp(); } catch (err) { note(form, err.status === 401 ? 'That username or password is not right.' : err.message, 'bad'); } }); }); $('#form-register').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); if (!data.email) data.email = null; try { await api('api/register', { method: 'POST', ...json(data) }); form.reset(); showPane('login'); note($('#form-login'), 'Account created. Log in below.', 'good'); } catch (err) { note(form, err.status === 401 ? 'That registration token is not right.' : err.message, 'bad'); } }); }); $('#form-forgot').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); await api('api/password-reset/request', { method: 'POST', ...json(data) }) .catch(() => {}); // Deliberately the same message either way — the server does not reveal // whether the address is registered, and neither does this. note(form, 'If that address is registered, a reset link is on its way.', 'good'); form.reset(); }); }); $('#form-reset').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); if (data.new_password !== data.confirm) { note(form, 'Those two passwords do not match.', 'bad'); return; } submitting(form, async () => { try { await api('api/password-reset/confirm', { method: 'POST', ...json({ token: resetToken(), new_password: data.new_password }), }); form.reset(); history.replaceState(null, '', location.pathname); showPane('login'); note($('#form-login'), 'Password updated. Log in with it now.', 'good'); } catch (err) { note(form, err.message, 'bad'); } }); }); const resetToken = () => new URLSearchParams(location.search).get('token'); /* ───────────────────────────── map ───────────────────────────── */ function initMap() { if (state.map) return; state.map = L.map('map', { zoomControl: true }).setView([54.5, -3.0], 6); L.tileLayer(tileUrl, { maxZoom: 19, attribution: TILE_ATTRIB }).addTo(state.map); state.map.on('click', (e) => openMarkerSheet(null, e.latlng)); state.map.on('move zoom', updateReadout); updateReadout(); } function updateReadout() { if (!state.map) return; const c = state.map.getCenter(); $('#readout-latlon').textContent = fmtCoord(c.lat, c.lng); $('#readout-zoom').textContent = String(state.map.getZoom()); } function pinIcon(colour) { return L.divIcon({ className: '', html: `
`, iconSize: [15, 15], iconAnchor: [7, 14], }); } /* ─────────────────────────── markers ─────────────────────────── */ async function loadMarkers() { state.markers = await api('api/markers'); renderMarkers(); } function renderMarkers() { state.layers.forEach((layer) => state.map.removeLayer(layer)); state.layers.clear(); const list = $('#marker-list'); list.innerHTML = ''; $('#marker-empty').hidden = state.markers.length > 0; state.markers.forEach((m) => { const mine = m.owner_id === state.me.id; const layer = L.marker([m.lat, m.lon], { icon: pinIcon(m.color) }).addTo(state.map); layer.bindPopup( `${escapeHtml(m.name)}` + (m.category ? `${escapeHtml(m.category)}
` : '') + (m.description ? `${escapeHtml(m.description)}
` : '') + `${fmtCoord(m.lat, m.lon)}` + (mine ? '' : '
shared with you') ); if (mine) layer.on('dblclick', () => openMarkerSheet(m)); state.layers.set(m.id, layer); const li = document.createElement('li'); li.innerHTML = `` + `` + `${fmtCoord(m.lat, m.lon)}`; $('.name', li).textContent = m.name; if (m.is_shared) { const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = mine ? 'shared' : 'theirs'; li.appendChild(badge); } const go = document.createElement('button'); go.className = 'link'; go.textContent = mine ? 'Edit' : 'Show'; go.addEventListener('click', () => { state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14)); state.layers.get(m.id).openPopup(); if (mine) openMarkerSheet(m); }); li.appendChild(go); list.appendChild(li); }); } function escapeHtml(s) { const div = document.createElement('div'); div.textContent = s == null ? '' : s; return div.innerHTML; } function buildSwatches() { const wrap = $('#swatches'); wrap.innerHTML = ''; COLOURS.forEach((c) => { const b = document.createElement('button'); b.type = 'button'; b.style.background = c; b.setAttribute('aria-label', 'Colour ' + c); b.setAttribute('aria-pressed', String(c === state.colour)); b.addEventListener('click', () => { state.colour = c; $$('#swatches button').forEach((x) => x.setAttribute('aria-pressed', String(x.style.background === b.style.background))); }); wrap.appendChild(b); }); } function openSheet(id) { $('#scrim').hidden = false; $(id).hidden = false; } function closeSheets() { $('#scrim').hidden = true; $('#marker-sheet').hidden = true; $('#account-sheet').hidden = true; state.editing = null; state.draftLatLng = null; } $('#scrim').addEventListener('click', closeSheets); $$('[data-close-sheet]').forEach((b) => b.addEventListener('click', closeSheets)); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheets(); }); function openMarkerSheet(marker, latlng) { const form = $('#marker-form'); form.reset(); note(form, ''); state.editing = marker || null; state.draftLatLng = marker ? { lat: marker.lat, lng: marker.lon } : latlng; state.colour = (marker && marker.color) || DEFAULT_COLOUR; $('#sheet-title').textContent = marker ? 'Edit place' : 'New place'; $('#marker-coords').textContent = fmtCoord(state.draftLatLng.lat, state.draftLatLng.lng); $('#marker-delete').hidden = !marker; if (marker) { form.name.value = marker.name; form.description.value = marker.description || ''; form.is_shared.checked = marker.is_shared; const known = Array.from(form.category.options).some((o) => o.value === marker.category); if (marker.category && !known) { form.category.value = '__other'; form.category_other.value = marker.category; } else { form.category.value = marker.category || ''; } } $('#category-other-wrap').hidden = form.category.value !== '__other'; buildSwatches(); openSheet('#marker-sheet'); form.name.focus(); } $('#marker-form').category.addEventListener('change', (e) => { $('#category-other-wrap').hidden = e.target.value !== '__other'; }); $('#marker-form').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); const category = data.category === '__other' ? (data.category_other || '').trim() : data.category; const body = { name: data.name, description: data.description || null, lat: state.draftLatLng.lat, lon: state.draftLatLng.lng, category: category || null, color: state.colour, is_shared: form.is_shared.checked, }; try { if (state.editing) { await api('api/markers/' + state.editing.id, { method: 'PUT', ...json(body) }); } else { await api('api/markers', { method: 'POST', ...json(body) }); } closeSheets(); await loadMarkers(); toast('Place saved'); } catch (err) { note(form, err.message, 'bad'); } }); }); $('#marker-delete').addEventListener('click', async () => { if (!state.editing) return; if (!confirm(`Delete "${state.editing.name}"?`)) return; try { await api('api/markers/' + state.editing.id, { method: 'DELETE' }); closeSheets(); await loadMarkers(); toast('Place deleted'); } catch (err) { toast(err.message, true); } }); /* ─────────────────────────── search ─────────────────────────── */ // Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style // search-as-you-type, so there is deliberately no keystroke handler here. $('#search').addEventListener('submit', async (e) => { e.preventDefault(); const q = e.target.q.value.trim(); if (!q) return; try { const url = `${NOMINATIM}?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`; const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); const hits = await res.json(); if (!hits.length) { toast('Nothing found for that', true); return; } const hit = hits[0]; state.map.setView([parseFloat(hit.lat), parseFloat(hit.lon)], 14); toast(hit.display_name.split(',').slice(0, 2).join(',')); } catch { toast('Search is unavailable right now', true); } }); /* ────────────────────── live location ────────────────────── */ $('#locate').addEventListener('click', () => { const button = $('#locate'); if (state.watchId !== null) { navigator.geolocation.clearWatch(state.watchId); state.watchId = null; if (state.hereLayer) { state.map.removeLayer(state.hereLayer); state.hereLayer = null; } button.setAttribute('aria-pressed', 'false'); return; } if (!navigator.geolocation) { toast('This browser has no location support', true); return; } // watchPosition, not getCurrentPosition — the dot should follow you. // Requires a secure context, so it will not run over plain HTTP. state.watchId = navigator.geolocation.watchPosition( (pos) => { const { latitude, longitude } = pos.coords; if (!state.hereLayer) { state.hereLayer = L.marker([latitude, longitude], { icon: L.divIcon({ className: '', html: '
', iconSize: [15, 15] }), interactive: false, }).addTo(state.map); state.map.setView([latitude, longitude], Math.max(state.map.getZoom(), 15)); } else { state.hereLayer.setLatLng([latitude, longitude]); } }, (err) => { toast(err.code === err.PERMISSION_DENIED ? 'Location permission was refused' : 'Could not get a location fix', true); button.setAttribute('aria-pressed', 'false'); state.watchId = null; }, { enableHighAccuracy: true, maximumAge: 5000, timeout: 20000 } ); button.setAttribute('aria-pressed', 'true'); }); /* ─────────────────────────── gpx ─────────────────────────── */ function parseGpx(text) { const doc = new DOMParser().parseFromString(text, 'application/xml'); if (doc.querySelector('parsererror')) throw new Error('That file is not valid GPX.'); const tracks = []; doc.querySelectorAll('trkseg').forEach((seg) => { const pts = Array.from(seg.querySelectorAll('trkpt')) .map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))]) .filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b)); if (pts.length) tracks.push(pts); }); const routePts = Array.from(doc.querySelectorAll('rte > rtept')) .map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))]) .filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b)); if (routePts.length) tracks.push(routePts); const waypoints = Array.from(doc.querySelectorAll('gpx > wpt')).map((p) => ({ lat: parseFloat(p.getAttribute('lat')), lon: parseFloat(p.getAttribute('lon')), name: (p.querySelector('name') || {}).textContent || '', })); if (!tracks.length && !waypoints.length) throw new Error('That file has no track in it.'); return { tracks, waypoints }; } function drawGpx(text, label) { const { tracks, waypoints } = parseGpx(text); if (state.gpxLayer) state.map.removeLayer(state.gpxLayer); const group = L.layerGroup(); tracks.forEach((pts) => { L.polyline(pts, { color: '#D2467F', weight: 4, opacity: 0.9 }).addTo(group); }); waypoints.forEach((w) => { L.circleMarker([w.lat, w.lon], { radius: 4, color: '#E2A93C', fillColor: '#E2A93C', fillOpacity: 1, }).bindPopup(escapeHtml(w.name || 'Waypoint')).addTo(group); }); group.addTo(state.map); state.gpxLayer = group; const bounds = L.latLngBounds([]); tracks.forEach((pts) => pts.forEach((p) => bounds.extend(p))); waypoints.forEach((w) => bounds.extend([w.lat, w.lon])); if (bounds.isValid()) state.map.fitBounds(bounds, { padding: [40, 40] }); toast('Showing ' + label); } async function loadGpxList() { const files = await api('api/gpx'); const list = $('#gpx-list'); list.innerHTML = ''; $('#gpx-empty').hidden = files.length > 0; files.forEach((f) => { const li = document.createElement('li'); li.innerHTML = '' + `${fmtSize(f.size)} · ${fmtDate(f.modified)}`; $('.name', li).textContent = f.name; const show = document.createElement('button'); show.className = 'link'; show.textContent = 'Show'; show.addEventListener('click', async () => { try { const res = await fetch('api/gpx/file/' + encodeURIComponent(f.name), { credentials: 'same-origin', }); if (!res.ok) throw new Error('Could not read that file.'); drawGpx(await res.text(), f.name); } catch (err) { toast(err.message, true); } }); li.appendChild(show); list.appendChild(li); }); } $('#gpx-local').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); drawGpx(text, file.name); state.localGpx = { name: file.name, text }; $('#local-name').textContent = file.name; $('#local-actions').hidden = false; } catch (err) { toast(err.message, true); state.localGpx = null; $('#local-actions').hidden = true; } e.target.value = ''; }); $('#gpx-save').addEventListener('click', async () => { if (!state.localGpx) return; const button = $('#gpx-save'); button.disabled = true; try { const body = new FormData(); body.append('file', new Blob([state.localGpx.text], { type: 'application/gpx+xml' }), state.localGpx.name); const result = await api('api/gpx/upload', { method: 'POST', body }); // The server may have renamed it to avoid a collision, so report what it used. toast(result.name === state.localGpx.name ? `Saved as ${result.name}` : `Saved as ${result.name} — that name was taken`); state.localGpx = null; $('#local-actions').hidden = true; await loadGpxList(); } catch (err) { toast(err.status === 413 ? 'That file is too large to upload.' : err.message, true); } finally { button.disabled = false; } }); $('#gpx-clear').addEventListener('click', () => { if (state.gpxLayer) { state.map.removeLayer(state.gpxLayer); state.gpxLayer = null; } }); /* ─────────────────────── panels & account ─────────────────────── */ $$('.tab').forEach((tab) => { tab.addEventListener('click', () => { const target = tab.dataset.panel; $$('.tab').forEach((t) => t.setAttribute('aria-pressed', String(t === tab))); $('#panel-places').hidden = target !== 'places'; $('#panel-routes').hidden = target !== 'routes'; if (target === 'routes') loadGpxList().catch(() => {}); }); }); $('#close-places').addEventListener('click', () => { $('#panel-places').hidden = true; $$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false')); }); $('#close-routes').addEventListener('click', () => { $('#panel-routes').hidden = true; $$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false')); }); const accountBtn = $('#account-btn'); const accountMenu = $('#account-menu'); accountBtn.addEventListener('click', () => { const open = accountMenu.hidden; accountMenu.hidden = !open; accountBtn.setAttribute('aria-expanded', String(open)); }); document.addEventListener('click', (e) => { if (!accountMenu.hidden && !e.target.closest('.account')) { accountMenu.hidden = true; accountBtn.setAttribute('aria-expanded', 'false'); } }); $$('#account-menu button').forEach((b) => { b.addEventListener('click', async () => { accountMenu.hidden = true; accountBtn.setAttribute('aria-expanded', 'false'); if (b.dataset.action === 'logout') { await api('api/logout', { method: 'POST' }).catch(() => {}); location.reload(); return; } $('#current-email').textContent = state.me.email || 'Not set'; $('#email-form').reset(); $('#password-form').reset(); note($('#email-form'), ''); note($('#password-form'), ''); $('#email-form').new_email.value = state.me.email || ''; openSheet('#account-sheet'); }); }); $('#email-form').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; submitting(form, async () => { const data = Object.fromEntries(new FormData(form)); try { await api('api/me/email', { method: 'POST', ...json({ current_password: data.current_password, new_email: data.new_email.trim() || null, }), }); state.me = await api('api/me'); $('#current-email').textContent = state.me.email || 'Not set'; form.current_password.value = ''; note(form, 'Email updated.', 'good'); } catch (err) { note(form, err.status === 401 ? 'Incorrect password.' : err.status === 409 ? 'That email is already in use.' : err.message, 'bad'); } }); }); $('#password-form').addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); if (data.new_password !== data.confirm) { note(form, 'Those two passwords do not match.', 'bad'); return; } submitting(form, async () => { try { await api('api/me/password', { method: 'POST', ...json({ current_password: data.current_password, new_password: data.new_password, }), }); form.reset(); // No redirect to login — existing sessions stay valid by design. note(form, 'Password updated.', 'good'); } catch (err) { note(form, err.status === 401 ? 'Incorrect password.' : err.message, 'bad'); } }); }); /* ─────────────────────────── boot ─────────────────────────── */ async function enterApp() { $('#auth').hidden = true; $('#app').hidden = false; $('#account-name').textContent = state.me.username; await loadClientConfig(); initMap(); state.map.invalidateSize(); await loadMarkers(); await loadGpxList().catch(() => {}); } async function boot() { if (resetToken()) { $('#auth').hidden = false; showPane('reset'); return; } try { state.me = await api('api/me'); await enterApp(); } catch { $('#auth').hidden = false; showPane('login'); } } boot();