rs_maps/frontend/app.js
2026-08-03 09:42:13 +01:00

777 lines
24 KiB
JavaScript

// frontend/app.js
/* Waymark — all URLs relative, resolved against the injected <base href>.
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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `<div class="pin" style="background:${colour || DEFAULT_COLOUR}"></div>`,
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(
`<b>${escapeHtml(m.name)}</b>` +
(m.category ? `<span>${escapeHtml(m.category)}</span><br>` : '') +
(m.description ? `${escapeHtml(m.description)}<br>` : '') +
`<span class="mono">${fmtCoord(m.lat, m.lon)}</span>` +
(mine ? '' : '<br><span class="mono">shared with you</span>')
);
if (mine) layer.on('dblclick', () => openMarkerSheet(m));
state.layers.set(m.id, layer);
const li = document.createElement('li');
li.innerHTML =
`<span class="dot" style="background:${m.color || DEFAULT_COLOUR}"></span>` +
`<span class="grow"><span class="name"></span>` +
`<span class="meta">${fmtCoord(m.lat, m.lon)}</span></span>`;
$('.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: '<div class="here"></div>', 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 =
'<span class="grow"><span class="name"></span>' +
`<span class="meta">${fmtSize(f.size)} · ${fmtDate(f.modified)}</span></span>`;
$('.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();