778 lines
24 KiB
JavaScript
778 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 = '© <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();
|