2026-08-03 09:42:13 +01:00
|
|
|
// frontend/app.js
|
2026-08-03 11:02:29 +01:00
|
|
|
/* rs_maps — all URLs relative, resolved against the injected <base href>.
|
2026-08-03 09:42:13 +01:00
|
|
|
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 = '© <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';
|
2026-08-03 13:13:47 +01:00
|
|
|
// Nominatim's search endpoint doesn't return object tags, so opening hours,
|
|
|
|
|
// phone and website need a second lookup by OSM id. Overpass is donated
|
|
|
|
|
// infrastructure like Nominatim: fine on an explicit click, not per result.
|
2026-08-03 14:36:08 +01:00
|
|
|
//
|
|
|
|
|
// Tried in order. The main overpass-api.de instance is by far the busiest and
|
|
|
|
|
// returns 504 under load even for a trivial single-object query, so the mirrors
|
|
|
|
|
// are a practical necessity rather than belt and braces.
|
|
|
|
|
const OVERPASS_ENDPOINTS = [
|
|
|
|
|
'https://overpass.kumi.systems/api/interpreter',
|
|
|
|
|
'https://overpass-api.de/api/interpreter',
|
|
|
|
|
'https://overpass.private.coffee/api/interpreter',
|
|
|
|
|
];
|
|
|
|
|
|
2026-08-03 14:47:33 +01:00
|
|
|
// Overpass queues requests when busy — a trivial single-object lookup has been
|
|
|
|
|
// observed taking over a minute before returning a perfectly good 200. 30s is
|
|
|
|
|
// the most that's reasonable to make someone wait; past that, give up quietly.
|
|
|
|
|
const OVERPASS_TIMEOUT_MS = 30000;
|
2026-08-03 09:42:13 +01:00
|
|
|
|
|
|
|
|
const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B'];
|
|
|
|
|
const DEFAULT_COLOUR = COLOURS[0];
|
2026-08-03 11:58:00 +01:00
|
|
|
// Deliberately not one of the saved-place colours: a search hit is transient
|
|
|
|
|
// and shouldn't be mistaken for something already saved.
|
|
|
|
|
const SEARCH_COLOUR = '#E8B23A';
|
2026-08-03 09:42:13 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-08-03 11:58:00 +01:00
|
|
|
searchPin: null, // transient pin for the current search hit
|
2026-08-03 14:13:58 +01:00
|
|
|
draw: { active: false, points: [], markers: [], line: null, guide: null,
|
2026-08-03 14:21:40 +01:00
|
|
|
timer: null, seq: 0, snapped: null, editing: null },
|
2026-08-03 09:42:13 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ───────────────────────────── 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);
|
|
|
|
|
|
2026-08-03 13:41:35 +01:00
|
|
|
state.map.on('click', (e) => {
|
|
|
|
|
if (state.draw.active) { addWaypoint(e.latlng); return; }
|
|
|
|
|
openMarkerSheet(null, e.latlng);
|
|
|
|
|
});
|
2026-08-03 09:42:13 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 11:58:00 +01:00
|
|
|
// The whole row jumps to the place and opens its popup. Previously only the
|
|
|
|
|
// button did anything, and for your own places it opened the edit sheet on
|
|
|
|
|
// top of the popup — so "edit then cancel" was the only way to just look.
|
|
|
|
|
li.tabIndex = 0;
|
|
|
|
|
li.addEventListener('click', () => focusMarker(m));
|
|
|
|
|
li.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusMarker(m); }
|
2026-08-03 09:42:13 +01:00
|
|
|
});
|
2026-08-03 11:58:00 +01:00
|
|
|
|
|
|
|
|
if (mine) {
|
|
|
|
|
const go = document.createElement('button');
|
|
|
|
|
go.className = 'link';
|
|
|
|
|
go.textContent = 'Edit';
|
|
|
|
|
// Without this the row handler also fires and the popup opens behind the sheet.
|
|
|
|
|
go.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
focusMarker(m);
|
|
|
|
|
openMarkerSheet(m);
|
|
|
|
|
});
|
|
|
|
|
li.appendChild(go);
|
|
|
|
|
}
|
2026-08-03 09:42:13 +01:00
|
|
|
|
|
|
|
|
list.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 11:58:00 +01:00
|
|
|
/* Centre the map on a marker and open its popup. */
|
|
|
|
|
function focusMarker(m) {
|
|
|
|
|
state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14));
|
|
|
|
|
const layer = state.layers.get(m.id);
|
|
|
|
|
if (layer) layer.openPopup();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 13:13:47 +01:00
|
|
|
|
2026-08-03 13:41:35 +01:00
|
|
|
|
|
|
|
|
/* ─────────────────────── route drawing (snapped) ─────────────────────── */
|
|
|
|
|
|
|
|
|
|
/* Waypoints are snapped server-side by brouter.de via POST /api/routes/calculate.
|
|
|
|
|
Nothing is snapped locally: the straight guide line below is only a preview of
|
|
|
|
|
the order of points, not the route that gets saved. */
|
|
|
|
|
|
|
|
|
|
const DRAW_COLOUR = '#4FA3D1';
|
|
|
|
|
|
2026-08-03 14:13:58 +01:00
|
|
|
/* brouter.de is donated infrastructure, so previews are debounced rather than
|
|
|
|
|
fired on every click and every pixel of a drag. */
|
|
|
|
|
const PREVIEW_DEBOUNCE_MS = 1000;
|
|
|
|
|
|
2026-08-03 13:41:35 +01:00
|
|
|
function drawReset() {
|
2026-08-03 14:13:58 +01:00
|
|
|
if (state.draw.timer) clearTimeout(state.draw.timer);
|
2026-08-03 13:41:35 +01:00
|
|
|
state.draw.markers.forEach((m) => state.map.removeLayer(m));
|
|
|
|
|
if (state.draw.line) state.map.removeLayer(state.draw.line);
|
2026-08-03 14:13:58 +01:00
|
|
|
if (state.draw.guide) state.map.removeLayer(state.draw.guide);
|
|
|
|
|
state.draw = { active: false, points: [], markers: [], line: null, guide: null,
|
2026-08-03 14:21:40 +01:00
|
|
|
timer: null, seq: 0, snapped: null, editing: null };
|
2026-08-03 13:41:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawSetActive(on) {
|
|
|
|
|
if (!on) { drawReset(); }
|
|
|
|
|
state.draw.active = on;
|
|
|
|
|
|
|
|
|
|
$('#draw-panel').hidden = !on;
|
|
|
|
|
$('#draw-start').textContent = on ? 'Drawing…' : 'New route';
|
|
|
|
|
$('#draw-start').disabled = on;
|
2026-08-03 14:21:40 +01:00
|
|
|
if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking';
|
2026-08-03 13:41:35 +01:00
|
|
|
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
|
|
|
|
|
if (on) drawUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmtDistance(metres) {
|
|
|
|
|
if (!metres) return '—';
|
|
|
|
|
return metres < 1000
|
2026-08-03 14:13:58 +01:00
|
|
|
? `${Math.round(metres)} m`
|
|
|
|
|
: `${(metres / 1000).toFixed(1)} km`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* The dashed guide shows the order of waypoints. It's replaced visually by the
|
|
|
|
|
snapped line once brouter answers, but kept underneath so there's always
|
|
|
|
|
something on screen while a preview is in flight. */
|
|
|
|
|
function drawGuide() {
|
|
|
|
|
const latlngs = state.draw.points.map((p) => [p.lat, p.lng]);
|
|
|
|
|
if (state.draw.guide) {
|
|
|
|
|
state.draw.guide.setLatLngs(latlngs);
|
|
|
|
|
} else {
|
|
|
|
|
state.draw.guide = L.polyline(latlngs, {
|
|
|
|
|
color: DRAW_COLOUR, weight: 1, opacity: .35, dashArray: '3 6', interactive: false,
|
|
|
|
|
}).addTo(state.map);
|
|
|
|
|
}
|
2026-08-03 13:41:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawUpdate() {
|
|
|
|
|
const points = state.draw.points;
|
|
|
|
|
$('#draw-n').textContent = points.length;
|
|
|
|
|
$('#draw-save').disabled = points.length < 2;
|
|
|
|
|
$('#draw-undo').disabled = points.length === 0;
|
|
|
|
|
|
2026-08-03 14:13:58 +01:00
|
|
|
drawGuide();
|
|
|
|
|
schedulePreview();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setPreviewStatus(text) {
|
|
|
|
|
$('#draw-dist').textContent = text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function schedulePreview() {
|
|
|
|
|
if (state.draw.timer) clearTimeout(state.draw.timer);
|
|
|
|
|
|
|
|
|
|
if (state.draw.points.length < 2) {
|
|
|
|
|
if (state.draw.line) { state.map.removeLayer(state.draw.line); state.draw.line = null; }
|
|
|
|
|
state.draw.snapped = null;
|
|
|
|
|
setPreviewStatus('—');
|
|
|
|
|
return;
|
2026-08-03 13:41:35 +01:00
|
|
|
}
|
2026-08-03 14:13:58 +01:00
|
|
|
|
|
|
|
|
setPreviewStatus('snapping…');
|
|
|
|
|
state.draw.timer = setTimeout(runPreview, PREVIEW_DEBOUNCE_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runPreview() {
|
|
|
|
|
const points = state.draw.points.slice();
|
|
|
|
|
// Responses can arrive out of order after a fast edit; only the newest counts.
|
|
|
|
|
const seq = (state.draw.seq += 1);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const body = { name: 'preview.gpx', waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })) };
|
|
|
|
|
const result = await api('api/routes/preview', { method: 'POST', ...json(body) });
|
|
|
|
|
|
|
|
|
|
if (seq !== state.draw.seq || !state.draw.active) return;
|
|
|
|
|
|
|
|
|
|
state.draw.snapped = result;
|
|
|
|
|
const latlngs = result.points.map((p) => [p[0], p[1]]);
|
|
|
|
|
|
|
|
|
|
if (state.draw.line) {
|
|
|
|
|
state.draw.line.setLatLngs(latlngs);
|
|
|
|
|
} else {
|
|
|
|
|
state.draw.line = L.polyline(latlngs, {
|
|
|
|
|
color: DRAW_COLOUR, weight: 4, opacity: .9,
|
|
|
|
|
}).addTo(state.map);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ascend = result.ascend_m ? ` · ${Math.round(result.ascend_m)} m ascent` : '';
|
|
|
|
|
setPreviewStatus(fmtDistance(result.length_m) + ascend);
|
|
|
|
|
markUnsnappable(false);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (seq !== state.draw.seq || !state.draw.active) return;
|
|
|
|
|
|
|
|
|
|
// Keep the last good line rather than clearing the map — usually only the
|
|
|
|
|
// most recent point is the problem, and it's about to be moved or undone.
|
|
|
|
|
setPreviewStatus('no route');
|
|
|
|
|
markUnsnappable(true);
|
|
|
|
|
toast(err.message || 'Could not snap that route', true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Flags the most recently added waypoint, which is nearly always the one that
|
|
|
|
|
can't be reached — brouter doesn't say which point failed. */
|
|
|
|
|
function markUnsnappable(bad) {
|
|
|
|
|
const last = state.draw.markers[state.draw.markers.length - 1];
|
|
|
|
|
if (!last) return;
|
|
|
|
|
const el = last.getElement();
|
|
|
|
|
if (el) el.classList.toggle('wp-bad', bad);
|
2026-08-03 13:41:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addWaypoint(latlng) {
|
|
|
|
|
state.draw.points.push(latlng);
|
|
|
|
|
|
|
|
|
|
const index = state.draw.points.length - 1;
|
|
|
|
|
const marker = L.marker(latlng, {
|
|
|
|
|
draggable: true,
|
|
|
|
|
icon: L.divIcon({
|
|
|
|
|
className: 'wp-icon',
|
|
|
|
|
html: `<div class="wp">${index + 1}</div>`,
|
|
|
|
|
iconSize: [22, 22], iconAnchor: [11, 11],
|
|
|
|
|
}),
|
|
|
|
|
}).addTo(state.map);
|
|
|
|
|
|
2026-08-03 14:13:58 +01:00
|
|
|
// Guide follows the drag live; the snapped preview waits for the debounce.
|
2026-08-03 13:41:35 +01:00
|
|
|
marker.on('drag', (e) => {
|
|
|
|
|
const i = state.draw.markers.indexOf(marker);
|
2026-08-03 14:13:58 +01:00
|
|
|
if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawGuide(); }
|
2026-08-03 13:41:35 +01:00
|
|
|
});
|
2026-08-03 14:13:58 +01:00
|
|
|
marker.on('dragend', drawUpdate);
|
2026-08-03 13:41:35 +01:00
|
|
|
|
|
|
|
|
state.draw.markers.push(marker);
|
|
|
|
|
drawUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function undoWaypoint() {
|
|
|
|
|
const marker = state.draw.markers.pop();
|
|
|
|
|
if (marker) state.map.removeLayer(marker);
|
|
|
|
|
state.draw.points.pop();
|
|
|
|
|
drawUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$('#draw-start').addEventListener('click', () => {
|
|
|
|
|
$('#draw-name').value = '';
|
|
|
|
|
drawSetActive(true);
|
|
|
|
|
toast('Click the map to add waypoints');
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 14:21:40 +01:00
|
|
|
$('#draw-cancel').addEventListener('click', () => {
|
|
|
|
|
if (state.draw.editing && !confirm('Discard changes to this route?')) return;
|
|
|
|
|
drawSetActive(false);
|
|
|
|
|
});
|
2026-08-03 13:41:35 +01:00
|
|
|
$('#draw-undo').addEventListener('click', undoWaypoint);
|
|
|
|
|
|
|
|
|
|
$('#draw-save').addEventListener('click', async () => {
|
|
|
|
|
const points = state.draw.points;
|
|
|
|
|
if (points.length < 2) { toast('Add at least two points', true); return; }
|
|
|
|
|
|
|
|
|
|
const name = $('#draw-name').value.trim();
|
|
|
|
|
if (!name) { toast('Give the route a name', true); return; }
|
|
|
|
|
|
|
|
|
|
const button = $('#draw-save');
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
button.textContent = 'Calculating…';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const body = {
|
|
|
|
|
name,
|
|
|
|
|
waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })),
|
2026-08-03 14:21:40 +01:00
|
|
|
// The backend replaces in place only when this matches the new name.
|
|
|
|
|
// Rename the file and the original is left alone, as a copy.
|
|
|
|
|
replace: state.draw.editing || null,
|
2026-08-03 13:41:35 +01:00
|
|
|
};
|
|
|
|
|
const result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
|
|
|
|
|
|
2026-08-03 14:21:40 +01:00
|
|
|
const renamed = state.draw.editing && result.filename !== state.draw.editing;
|
|
|
|
|
toast(
|
|
|
|
|
!state.draw.editing ? `Saved as ${result.filename}`
|
|
|
|
|
: renamed ? `Saved as ${result.filename} — ${state.draw.editing} kept`
|
|
|
|
|
: `${result.filename} updated`
|
|
|
|
|
);
|
2026-08-03 13:41:35 +01:00
|
|
|
drawSetActive(false);
|
|
|
|
|
await loadGpxList();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// The backend forwards brouter's own explanation when a point can't be
|
|
|
|
|
// snapped to a path, which is more useful than a generic failure.
|
|
|
|
|
toast(err.message || 'Could not calculate that route', true);
|
|
|
|
|
} finally {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
button.textContent = 'Calculate & save';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 13:13:47 +01:00
|
|
|
/* ─────────────────────── place details (Overpass) ─────────────────────── */
|
|
|
|
|
|
|
|
|
|
/* Tags worth showing, in display order. `keys` is tried in order — OSM has both
|
|
|
|
|
bare and contact:-prefixed forms for most contact details. */
|
|
|
|
|
const DETAIL_ROWS = [
|
|
|
|
|
{ keys: ['opening_hours'], label: 'Hours', kind: 'hours' },
|
|
|
|
|
{ keys: ['phone', 'contact:phone'], label: 'Phone', kind: 'tel' },
|
|
|
|
|
{ keys: ['website', 'contact:website', 'url'],label: 'Website', kind: 'link' },
|
|
|
|
|
{ keys: ['email', 'contact:email'], label: 'Email', kind: 'email' },
|
|
|
|
|
{ keys: ['operator'], label: 'Operator', kind: 'text' },
|
|
|
|
|
{ keys: ['brand'], label: 'Brand', kind: 'text' },
|
|
|
|
|
{ keys: ['cuisine'], label: 'Cuisine', kind: 'text' },
|
|
|
|
|
{ keys: ['wheelchair'], label: 'Step-free',kind: 'text' },
|
|
|
|
|
{ keys: ['description'], label: 'Notes', kind: 'text' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const OSM_SHORT = { node: 'node', way: 'way', relation: 'rel' };
|
|
|
|
|
|
|
|
|
|
/* One Overpass call for a single object's tags. */
|
|
|
|
|
async function fetchOsmTags(osmType, osmId) {
|
|
|
|
|
const short = OSM_SHORT[osmType];
|
|
|
|
|
if (!short) return null;
|
|
|
|
|
|
2026-08-03 14:47:33 +01:00
|
|
|
// Server-side budget matched to ours, so it sheds the request rather than
|
|
|
|
|
// holding it in a queue we've already stopped waiting on.
|
|
|
|
|
const query = `[out:json][timeout:30];${short}(${osmId});out tags;`;
|
2026-08-03 14:36:08 +01:00
|
|
|
let lastError = null;
|
|
|
|
|
|
|
|
|
|
for (const endpoint of OVERPASS_ENDPOINTS) {
|
|
|
|
|
// AbortController rather than relying on the server: a hung connection
|
|
|
|
|
// would otherwise block the fallback for as long as the browser allows.
|
|
|
|
|
const abort = new AbortController();
|
|
|
|
|
const timer = setTimeout(() => abort.abort(), OVERPASS_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(endpoint, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
|
|
|
body: 'data=' + encodeURIComponent(query),
|
|
|
|
|
signal: abort.signal,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 429 and 504 mean this mirror is busy, not that the object is missing —
|
|
|
|
|
// worth asking someone else.
|
|
|
|
|
if (!res.ok) throw new Error('overpass ' + res.status);
|
|
|
|
|
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
return (body.elements && body.elements[0] && body.elements[0].tags) || {};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastError = err;
|
|
|
|
|
console.warn('overpass mirror failed:', endpoint, err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-03 13:13:47 +01:00
|
|
|
|
2026-08-03 14:36:08 +01:00
|
|
|
throw lastError || new Error('no overpass endpoint responded');
|
2026-08-03 13:13:47 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Renders whatever came back. Deliberately raw values — no "open now", no
|
|
|
|
|
parsing of the opening_hours grammar beyond splitting it onto its own lines. */
|
|
|
|
|
function renderDetails(tags) {
|
|
|
|
|
const wrap = document.createElement('div');
|
|
|
|
|
wrap.className = 'details';
|
|
|
|
|
let shown = 0;
|
|
|
|
|
|
|
|
|
|
DETAIL_ROWS.forEach((row) => {
|
|
|
|
|
const key = row.keys.find((k) => tags[k]);
|
|
|
|
|
if (!key) return;
|
|
|
|
|
const value = tags[key];
|
|
|
|
|
shown += 1;
|
|
|
|
|
|
|
|
|
|
const dt = document.createElement('span');
|
|
|
|
|
dt.className = 'd-label';
|
|
|
|
|
dt.textContent = row.label;
|
|
|
|
|
|
|
|
|
|
const dd = document.createElement('span');
|
|
|
|
|
dd.className = 'd-value';
|
|
|
|
|
|
|
|
|
|
if (row.kind === 'hours') {
|
|
|
|
|
// "Mo-Fr 08:00-18:00; Sa 09:00-17:00" is far more readable one clause
|
|
|
|
|
// per line, and that's as far as this goes — no interpretation.
|
|
|
|
|
dd.classList.add('d-hours');
|
|
|
|
|
value.split(';').forEach((clause) => {
|
|
|
|
|
const line = document.createElement('span');
|
|
|
|
|
line.textContent = clause.trim();
|
|
|
|
|
dd.appendChild(line);
|
|
|
|
|
});
|
|
|
|
|
} else if (row.kind === 'link' || row.kind === 'tel' || row.kind === 'email') {
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.textContent = value;
|
|
|
|
|
a.href = row.kind === 'tel' ? 'tel:' + value.replace(/\s+/g, '')
|
|
|
|
|
: row.kind === 'email' ? 'mailto:' + value
|
|
|
|
|
: /^https?:/i.test(value) ? value : 'https://' + value;
|
|
|
|
|
if (row.kind === 'link') { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
|
|
|
|
|
dd.appendChild(a);
|
|
|
|
|
} else {
|
|
|
|
|
dd.textContent = value.replace(/_/g, ' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wrap.append(dt, dd);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!shown) {
|
|
|
|
|
const empty = document.createElement('p');
|
|
|
|
|
empty.className = 'd-empty';
|
|
|
|
|
empty.textContent = 'No extra details recorded in OpenStreetMap.';
|
|
|
|
|
return { node: empty, shown };
|
|
|
|
|
}
|
|
|
|
|
return { node: wrap, shown };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
/* ─────────────────────────── search ─────────────────────────── */
|
|
|
|
|
|
|
|
|
|
// Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style
|
|
|
|
|
// search-as-you-type, so there is deliberately no keystroke handler here.
|
2026-08-03 11:58:00 +01:00
|
|
|
|
|
|
|
|
const SEARCH_LIMIT = 8;
|
|
|
|
|
|
|
|
|
|
function clearSearchPin() {
|
|
|
|
|
if (state.searchPin) {
|
|
|
|
|
state.map.removeLayer(state.searchPin);
|
|
|
|
|
state.searchPin = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideResults() {
|
|
|
|
|
const box = $('#search-results');
|
|
|
|
|
box.hidden = true;
|
|
|
|
|
box.innerHTML = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Nominatim's display_name is a long comma-separated string. The first part is
|
|
|
|
|
the place itself; the rest is the address, which is what distinguishes one
|
|
|
|
|
Tesco from the next. */
|
|
|
|
|
function splitName(hit) {
|
|
|
|
|
const parts = (hit.display_name || '').split(',').map((p) => p.trim());
|
|
|
|
|
return { head: parts[0] || hit.name || 'Unnamed', rest: parts.slice(1).join(', ') };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderResults(hits) {
|
|
|
|
|
const box = $('#search-results');
|
|
|
|
|
box.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
hits.forEach((hit) => {
|
|
|
|
|
const { head, rest } = splitName(hit);
|
|
|
|
|
const li = document.createElement('li');
|
|
|
|
|
const button = document.createElement('button');
|
|
|
|
|
button.type = 'button';
|
|
|
|
|
|
|
|
|
|
const name = document.createElement('span');
|
|
|
|
|
name.className = 'r-name';
|
|
|
|
|
name.textContent = head;
|
|
|
|
|
|
|
|
|
|
const where = document.createElement('span');
|
|
|
|
|
where.className = 'r-where';
|
|
|
|
|
where.textContent = rest;
|
|
|
|
|
|
|
|
|
|
button.append(name, where);
|
|
|
|
|
|
|
|
|
|
// e.g. "supermarket", "cafe" — helps tell near-identical entries apart.
|
|
|
|
|
const kind = hit.type || hit.category;
|
|
|
|
|
if (kind) {
|
|
|
|
|
const tag = document.createElement('span');
|
|
|
|
|
tag.className = 'r-kind';
|
|
|
|
|
tag.textContent = String(kind).replace(/_/g, ' ');
|
|
|
|
|
button.appendChild(tag);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
button.addEventListener('click', () => { showSearchHit(hit); hideResults(); });
|
|
|
|
|
li.appendChild(button);
|
|
|
|
|
box.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
box.hidden = hits.length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Drop a pin at the hit, centre on it, and show what was actually found —
|
|
|
|
|
previously the map jumped with nothing to indicate where or what. */
|
|
|
|
|
function showSearchHit(hit) {
|
|
|
|
|
clearSearchPin();
|
|
|
|
|
|
|
|
|
|
const lat = parseFloat(hit.lat);
|
|
|
|
|
const lon = parseFloat(hit.lon);
|
|
|
|
|
const { head, rest } = splitName(hit);
|
|
|
|
|
const kind = (hit.type || hit.category || '').replace(/_/g, ' ');
|
|
|
|
|
|
2026-08-03 13:13:47 +01:00
|
|
|
const osmLink = hit.osm_type && hit.osm_id
|
|
|
|
|
? `https://www.openstreetmap.org/${hit.osm_type}/${hit.osm_id}`
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-08-03 11:58:00 +01:00
|
|
|
state.searchPin = L.marker([lat, lon], { icon: pinIcon(SEARCH_COLOUR) }).addTo(state.map);
|
|
|
|
|
state.searchPin.bindPopup(
|
|
|
|
|
`<b>${escapeHtml(head)}</b>` +
|
|
|
|
|
(kind ? `<span>${escapeHtml(kind)}</span><br>` : '') +
|
|
|
|
|
(rest ? `${escapeHtml(rest)}<br>` : '') +
|
|
|
|
|
`<span class="mono">${fmtCoord(lat, lon)}</span>` +
|
2026-08-03 13:13:47 +01:00
|
|
|
`<div class="detail-slot"></div>` +
|
|
|
|
|
`<div class="popup-actions">` +
|
|
|
|
|
`<button type="button" class="link" data-save-hit>Save as place</button>` +
|
|
|
|
|
(osmLink ? `<button type="button" class="link" data-more>More details</button>` : '') +
|
|
|
|
|
(osmLink ? `<a class="link" href="${osmLink}" target="_blank" rel="noopener noreferrer">View on OSM</a>` : '') +
|
|
|
|
|
`</div>`,
|
|
|
|
|
{ minWidth: 210, maxWidth: 300 }
|
2026-08-03 11:58:00 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The popup is rebuilt each time it opens, so the handler is attached here
|
|
|
|
|
// rather than once at bind time.
|
|
|
|
|
state.searchPin.on('popupopen', (e) => {
|
2026-08-03 13:13:47 +01:00
|
|
|
const popup = e.popup;
|
|
|
|
|
const root = popup.getElement();
|
|
|
|
|
|
|
|
|
|
// Details are fetched on demand, once per popup opening. Overpass is
|
|
|
|
|
// donated infrastructure — one call per deliberate click, never per result.
|
|
|
|
|
const more = root.querySelector('[data-more]');
|
|
|
|
|
const slot = root.querySelector('.detail-slot');
|
|
|
|
|
|
|
|
|
|
if (more && slot) {
|
|
|
|
|
more.addEventListener('click', async () => {
|
|
|
|
|
more.disabled = true;
|
|
|
|
|
more.textContent = 'Loading…';
|
|
|
|
|
try {
|
|
|
|
|
const tags = await fetchOsmTags(hit.osm_type, hit.osm_id);
|
|
|
|
|
const { node } = renderDetails(tags || {});
|
|
|
|
|
slot.replaceChildren(node);
|
|
|
|
|
more.remove();
|
|
|
|
|
} catch {
|
2026-08-03 14:47:33 +01:00
|
|
|
// Overpass being busy is routine and not worth interrupting anyone
|
|
|
|
|
// over. The button simply comes back so it can be pressed again;
|
|
|
|
|
// the reason stays in the console.
|
2026-08-03 13:13:47 +01:00
|
|
|
more.disabled = false;
|
|
|
|
|
more.textContent = 'More details';
|
|
|
|
|
}
|
|
|
|
|
popup.update(); // re-measure after the content grew
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const save = root.querySelector('[data-save-hit]');
|
2026-08-03 11:58:00 +01:00
|
|
|
if (!save) return;
|
|
|
|
|
save.addEventListener('click', () => {
|
|
|
|
|
state.searchPin.closePopup();
|
|
|
|
|
openMarkerSheet(null, { lat, lng: lon });
|
|
|
|
|
const form = $('#marker-form');
|
|
|
|
|
form.name.value = head;
|
|
|
|
|
|
|
|
|
|
// category is a fixed <select>; Nominatim's type rarely matches an option,
|
|
|
|
|
// so only preselect on an exact hit and otherwise leave it unclassified.
|
|
|
|
|
const match = [...form.category.options].some((o) => o.value === kind);
|
|
|
|
|
if (match) form.category.value = kind;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
state.map.setView([lat, lon], Math.max(state.map.getZoom(), 16));
|
|
|
|
|
state.searchPin.openPopup();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
$('#search').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const q = e.target.q.value.trim();
|
|
|
|
|
if (!q) return;
|
|
|
|
|
|
2026-08-03 11:58:00 +01:00
|
|
|
hideResults();
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
try {
|
2026-08-03 11:58:00 +01:00
|
|
|
// Ask for several: a chain name in a city has many equally valid matches,
|
|
|
|
|
// and limit=1 picked one arbitrarily with no way to see the others.
|
|
|
|
|
const url = `${NOMINATIM}?format=jsonv2&addressdetails=1&limit=${SEARCH_LIMIT}` +
|
|
|
|
|
`&q=${encodeURIComponent(q)}`;
|
|
|
|
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
2026-08-03 09:42:13 +01:00
|
|
|
const hits = await res.json();
|
|
|
|
|
|
|
|
|
|
if (!hits.length) { toast('Nothing found for that', true); return; }
|
2026-08-03 11:58:00 +01:00
|
|
|
|
|
|
|
|
if (hits.length === 1) {
|
|
|
|
|
showSearchHit(hits[0]);
|
|
|
|
|
} else {
|
|
|
|
|
renderResults(hits);
|
|
|
|
|
toast(`${hits.length} matches — pick one`);
|
|
|
|
|
}
|
2026-08-03 09:42:13 +01:00
|
|
|
} catch {
|
|
|
|
|
toast('Search is unavailable right now', true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 11:58:00 +01:00
|
|
|
// Dismiss the results list on outside click or Escape.
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
if (!e.target.closest('#search')) hideResults();
|
|
|
|
|
});
|
|
|
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideResults(); });
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
/* ────────────────────── 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);
|
2026-08-03 14:21:40 +01:00
|
|
|
|
|
|
|
|
const edit = document.createElement('button');
|
|
|
|
|
edit.className = 'link';
|
|
|
|
|
edit.textContent = 'Edit';
|
|
|
|
|
edit.addEventListener('click', () => editRoute(f.name));
|
|
|
|
|
li.appendChild(edit);
|
|
|
|
|
|
2026-08-03 20:14:22 +01:00
|
|
|
// A plain anchor rather than a fetch-and-blob: same-origin navigation
|
|
|
|
|
// carries the session cookie, and the browser handles the save dialog,
|
|
|
|
|
// resumability and large files without any of it passing through JS.
|
|
|
|
|
const download = document.createElement('a');
|
|
|
|
|
download.className = 'link';
|
|
|
|
|
download.textContent = 'Download';
|
|
|
|
|
download.href = 'api/gpx/file/' + encodeURIComponent(f.name);
|
|
|
|
|
// Without an explicit filename the browser would name it after the URL's
|
|
|
|
|
// last segment, which is percent-encoded.
|
|
|
|
|
download.download = f.name;
|
|
|
|
|
li.appendChild(download);
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
list.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 14:21:40 +01:00
|
|
|
/* Reopens a saved route for editing. Only routes created here can be edited:
|
|
|
|
|
they carry their original <wpt> waypoints alongside the dense snapped track.
|
|
|
|
|
A GPX recorded on a device has no such waypoints, and a few thousand track
|
|
|
|
|
points can't be reduced back to the handful someone actually clicked. */
|
|
|
|
|
async function editRoute(filename) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('api/gpx/file/' + encodeURIComponent(filename), {
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Could not read that file.');
|
|
|
|
|
|
|
|
|
|
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml');
|
|
|
|
|
const wpts = [...doc.getElementsByTagName('wpt')]
|
|
|
|
|
.map((w) => ({
|
|
|
|
|
lat: parseFloat(w.getAttribute('lat')),
|
|
|
|
|
lng: parseFloat(w.getAttribute('lon')),
|
|
|
|
|
}))
|
|
|
|
|
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng));
|
|
|
|
|
|
|
|
|
|
if (wpts.length < 2) {
|
|
|
|
|
toast('That file has no editable waypoints — only routes drawn here can be edited', true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
drawSetActive(true);
|
|
|
|
|
state.draw.editing = filename;
|
|
|
|
|
// Strip the extension: it's re-added on save, and showing it invites
|
|
|
|
|
// someone to delete it by accident.
|
|
|
|
|
$('#draw-name').value = filename.replace(/\.gpx$/i, '');
|
|
|
|
|
$('#draw-hint').textContent = `Editing ${filename}`;
|
|
|
|
|
|
|
|
|
|
wpts.forEach((p) => addWaypoint(L.latLng(p.lat, p.lng)));
|
|
|
|
|
state.map.fitBounds(L.latLngBounds(wpts.map((p) => [p.lat, p.lng])), { padding: [40, 40] });
|
|
|
|
|
toast('Drag points to adjust, then save');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast(err.message || 'Could not open that route', true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 09:42:13 +01:00
|
|
|
$('#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();
|