1793 lines
66 KiB
JavaScript
1793 lines
66 KiB
JavaScript
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||
|
||
(function (ns) {
|
||
'use strict';
|
||
|
||
const ids = Object.freeze({
|
||
addPoi: 'editor-add-poi',
|
||
button: 'editor-button',
|
||
close: 'editor-close',
|
||
dialog: 'editor-dialog',
|
||
dialogTitle: 'editor-dialog-title',
|
||
newRoute: 'editor-new-route',
|
||
poiAudio: 'editor-poi-audio',
|
||
poiAudioAdd: 'editor-poi-audio-add',
|
||
poiCancel: 'editor-poi-cancel',
|
||
poiDelete: 'editor-poi-delete',
|
||
poiDescription: 'editor-poi-description',
|
||
poiForm: 'editor-poi-form',
|
||
poiFormTitle: 'editor-poi-form-title',
|
||
poiFormView: 'editor-poi-form-view',
|
||
poiId: 'editor-poi-id',
|
||
poiImages: 'editor-poi-images',
|
||
poiImagesAdd: 'editor-poi-images-add',
|
||
poiLat: 'editor-poi-lat',
|
||
poiList: 'editor-poi-list',
|
||
poiLon: 'editor-poi-lon',
|
||
poiMediaPreview: 'editor-poi-media-preview',
|
||
poiMediaStatus: 'editor-poi-media-status',
|
||
poiRadius: 'editor-poi-radius',
|
||
poiSave: 'editor-poi-save',
|
||
poiTitle: 'editor-poi-title',
|
||
routeActivated: 'editor-route-activated',
|
||
routeBack: 'editor-route-back',
|
||
routeDescription: 'editor-route-description',
|
||
routeDetailTitle: 'editor-route-detail-title',
|
||
routeDetailView: 'editor-route-detail-view',
|
||
routeForm: 'editor-route-form',
|
||
routeGpx: 'editor-route-gpx',
|
||
routeGpxGroup: 'editor-route-gpx-group',
|
||
routeId: 'editor-route-id',
|
||
routeList: 'editor-route-list',
|
||
routeListTitle: 'editor-route-list-title',
|
||
routeListView: 'editor-route-list-view',
|
||
routeName: 'editor-route-name',
|
||
routeSave: 'editor-route-save',
|
||
routeUpload: 'editor-route-upload',
|
||
rightsOpen: 'editor-rights-open',
|
||
status: 'editor-status'
|
||
});
|
||
|
||
const classes = Object.freeze({
|
||
button: 'editor-button',
|
||
dialog: 'editor-dialog',
|
||
dialogHeader: 'editor-dialog-header',
|
||
dangerButton: 'editor-danger-button',
|
||
dangerIcon: 'editor-danger-icon',
|
||
dialogShell: 'editor-dialog-shell',
|
||
formActions: 'editor-form-actions',
|
||
form: 'editor-form',
|
||
gpxGroup: 'editor-gpx-group',
|
||
iconButton: 'editor-icon-button',
|
||
iconDanger: 'editor-icon-danger',
|
||
iconRow: 'editor-icon-row',
|
||
mediaActions: 'editor-media-actions',
|
||
mediaButton: 'editor-media-button',
|
||
mediaIcon: 'editor-media-icon',
|
||
mediaPreview: 'editor-media-preview',
|
||
mediaPreviewAudio: 'editor-media-preview-audio',
|
||
mediaPreviewCard: 'editor-media-preview-card',
|
||
mediaPreviewImage: 'editor-media-preview-image',
|
||
mediaPreviewSelect: 'editor-media-preview-select',
|
||
mediaRemove: 'editor-media-remove',
|
||
muted: 'editor-muted',
|
||
poiItem: 'editor-poi-item',
|
||
poiList: 'editor-poi-list',
|
||
poiToolbar: 'editor-poi-toolbar',
|
||
primaryButton: 'editor-primary-button',
|
||
routeGroup: 'editor-route-group',
|
||
routeItem: 'editor-route-item',
|
||
routeList: 'editor-route-list',
|
||
roundButton: 'editor-round-button',
|
||
secondaryButton: 'editor-secondary-button',
|
||
status: 'editor-status',
|
||
toolbar: 'editor-toolbar',
|
||
toolbarBack: 'editor-toolbar-back',
|
||
view: 'editor-view'
|
||
});
|
||
|
||
const state = {
|
||
currentPoi: null,
|
||
groupDeletions: [],
|
||
groupDeletePoint: false,
|
||
currentRoute: null,
|
||
currentSource: null,
|
||
pendingAudioFile: null,
|
||
pendingAudioHandle: null,
|
||
pendingImages: [],
|
||
pois: [],
|
||
routes: [],
|
||
selectedMediaKey: null
|
||
};
|
||
|
||
function ready(callback) {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||
return;
|
||
}
|
||
callback();
|
||
}
|
||
|
||
function byId(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function text(value) {
|
||
return String(value ?? '');
|
||
}
|
||
|
||
function numberOrNull(value) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function routeId(route) {
|
||
return text(route?.id ?? route?.routeId);
|
||
}
|
||
|
||
function poiId(poi) {
|
||
return text(poi?.id ?? poi?.poiId);
|
||
}
|
||
|
||
function numericPoiId(value) {
|
||
const raw = text(value).trim();
|
||
if (!raw) return null;
|
||
const parsed = Number(raw);
|
||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||
}
|
||
|
||
function nextLocalPoiId() {
|
||
let highest = -1;
|
||
for (const poi of state.pois) {
|
||
const identifier = numericPoiId(poi?.id ?? poi?.poiId);
|
||
if (identifier != null && identifier > highest) highest = identifier;
|
||
}
|
||
const next = highest + 1;
|
||
if (!Number.isSafeInteger(next)) {
|
||
throw new TypeError(ns.I18n.t("editor.error.add-station"));
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function routeName(route) {
|
||
return text(route?.name || route?.title || ns.I18n.t("editor.untitled-route"));
|
||
}
|
||
|
||
function poiTitle(poi) {
|
||
return text(poi?.title || poi?.name || ns.I18n.t("editor.untitled-station"));
|
||
}
|
||
|
||
function apiBase() {
|
||
const base = ns.Config?.apiBase || '/api';
|
||
return base.endsWith('/') ? base.slice(0, -1) : base;
|
||
}
|
||
|
||
function apiUrl(path) {
|
||
return `${apiBase()}${path}`;
|
||
}
|
||
|
||
function setHidden(element, hidden) {
|
||
if (element) element.hidden = hidden;
|
||
}
|
||
|
||
function setExpanded(element, expanded) {
|
||
if (element) element.setAttribute('aria-expanded', String(expanded));
|
||
}
|
||
|
||
function setDialogExpanded(expanded) {
|
||
setExpanded(byId(ids.button), expanded);
|
||
}
|
||
|
||
function setDisabled(element, disabled) {
|
||
if (!element) return;
|
||
element.disabled = disabled;
|
||
element.setAttribute('aria-disabled', String(disabled));
|
||
}
|
||
|
||
function setStatus(message, isError = false) {
|
||
const status = byId(ids.status);
|
||
if (!status) return;
|
||
status.textContent = isError ? (ns.UserMessages?.error(message) || message) : message;
|
||
status.hidden = !message;
|
||
status.dataset.kind = isError ? 'error' : 'notice';
|
||
}
|
||
|
||
function setDialogTitle(value) {
|
||
const title = byId(ids.dialogTitle);
|
||
if (title) title.textContent = value;
|
||
}
|
||
|
||
function routeDialogTitle() {
|
||
if (state.currentSource === 'local') {
|
||
return routeId(state.currentRoute) ? ns.I18n.t("editor.edit-draft") : ns.I18n.t("editor.new-route");
|
||
}
|
||
return ns.I18n.t("editor.view-route");
|
||
}
|
||
|
||
let poiMediaGeneration = 0;
|
||
let previewPoi = null;
|
||
|
||
function releasePoiPreview() {
|
||
poiMediaGeneration += 1;
|
||
ns.LocalRoutes?.releasePoiMedia?.(previewPoi);
|
||
previewPoi = null;
|
||
}
|
||
|
||
function showListView() {
|
||
ns.GroupPoints?.release();
|
||
releasePoiPreview();
|
||
ns.RouteRecorder?.endEditing();
|
||
setDialogTitle('Streckenverwaltung');
|
||
setHidden(byId(ids.routeListView), false);
|
||
setHidden(byId(ids.routeDetailView), true);
|
||
setHidden(byId(ids.poiFormView), true);
|
||
}
|
||
|
||
function showRouteView() {
|
||
ns.GroupPoints?.release();
|
||
releasePoiPreview();
|
||
setDialogTitle(routeDialogTitle());
|
||
setHidden(byId(ids.routeListView), true);
|
||
setHidden(byId(ids.routeDetailView), false);
|
||
setHidden(byId(ids.poiFormView), true);
|
||
}
|
||
|
||
function showPoiView() {
|
||
setDialogTitle(state.currentPoi ? ns.I18n.t("editor.edit-station") : ns.I18n.t("editor.add-station"));
|
||
setHidden(byId(ids.routeListView), true);
|
||
setHidden(byId(ids.routeDetailView), true);
|
||
setHidden(byId(ids.poiFormView), false);
|
||
}
|
||
|
||
function isActivation(event) {
|
||
if (event.type === 'pointerup') {
|
||
if (event.isPrimary === false) return false;
|
||
return event.pointerType !== 'mouse' || event.button === 0;
|
||
}
|
||
return event.type === 'keydown' && (event.key === 'Enter' || event.key === ' ');
|
||
}
|
||
|
||
function activate(element, handler) {
|
||
if (!element) return;
|
||
element.addEventListener('pointerup', event => {
|
||
if (!isActivation(event)) return;
|
||
event.preventDefault();
|
||
handler(event);
|
||
});
|
||
element.addEventListener('keydown', event => {
|
||
if (!isActivation(event)) return;
|
||
event.preventDefault();
|
||
handler(event);
|
||
});
|
||
}
|
||
|
||
function submit(form, handler) {
|
||
if (!form) return;
|
||
form.addEventListener('submit', event => {
|
||
event.preventDefault();
|
||
handler(event);
|
||
});
|
||
}
|
||
|
||
async function parseJson(response) {
|
||
const raw = await response.text();
|
||
if (!raw.trim()) return {};
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
async function handleResponse(response) {
|
||
if ([401, 403].includes(response.status) && state.currentSource === 'group') {
|
||
throw new Error(ns.I18n.t('groups.error.access'));
|
||
}
|
||
if (response.status === 423) throw new Error(ns.I18n.t('point-locks.lost'));
|
||
if (response.status === 409 && response.headers?.get('X-Group-Route')) {
|
||
throw new Error(ns.I18n.t('groups.error.conflict'));
|
||
}
|
||
if (response.ok) {
|
||
ns.Groups?.observe?.(response);
|
||
ns.GroupPoints?.observe?.(response);
|
||
return parseJson(response);
|
||
}
|
||
const payload = await parseJson(response);
|
||
const error = new Error(payload.message || ns.I18n.t("editor.error.action"));
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
|
||
async function readJson(path) {
|
||
const response = await fetch(apiUrl(path), {
|
||
credentials: 'same-origin',
|
||
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' }
|
||
});
|
||
return handleResponse(response);
|
||
}
|
||
|
||
async function sendJson(path, method, body) {
|
||
const response = await fetch(apiUrl(path), {
|
||
body: JSON.stringify(body),
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path),
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json'
|
||
},
|
||
method
|
||
});
|
||
return handleResponse(response);
|
||
}
|
||
|
||
async function sendForm(path, method, formData) {
|
||
const response = await fetch(apiUrl(path), {
|
||
body: formData,
|
||
credentials: 'same-origin',
|
||
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
|
||
method
|
||
});
|
||
return handleResponse(response);
|
||
}
|
||
|
||
async function sendDelete(path) {
|
||
const response = await fetch(apiUrl(path), {
|
||
credentials: 'same-origin',
|
||
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
|
||
method: 'DELETE'
|
||
});
|
||
return handleResponse(response);
|
||
}
|
||
|
||
function routeArray(payload) {
|
||
if (Array.isArray(payload)) return payload;
|
||
if (Array.isArray(payload?.routes)) return payload.routes;
|
||
if (Array.isArray(payload?.items)) return payload.items;
|
||
return [];
|
||
}
|
||
|
||
function poiArray(route) {
|
||
if (Array.isArray(route?.pois)) return route.pois;
|
||
if (Array.isArray(route?.pointsOfInterest)) return route.pointsOfInterest;
|
||
return [];
|
||
}
|
||
|
||
function appendText(parent, tagName, value) {
|
||
const child = document.createElement(tagName);
|
||
child.textContent = value;
|
||
parent.appendChild(child);
|
||
return child;
|
||
}
|
||
|
||
function iconButton(label, symbol, action, disabled = false, danger = false) {
|
||
const button = document.createElement('button');
|
||
button.className = danger ? `${classes.iconButton} ${classes.iconDanger}` : classes.iconButton;
|
||
button.dataset.editorAction = action;
|
||
button.textContent = symbol;
|
||
button.title = label;
|
||
button.type = 'button';
|
||
button.setAttribute('aria-label', label);
|
||
setDisabled(button, disabled);
|
||
return button;
|
||
}
|
||
|
||
function routeSource(route) {
|
||
return route?._source || route?.source || 'server';
|
||
}
|
||
|
||
function routeItem(route) {
|
||
const item = document.createElement('li');
|
||
item.className = classes.routeItem;
|
||
item.dataset.routeId = routeId(route);
|
||
item.dataset.routeSource = routeSource(route);
|
||
|
||
const label = document.createElement('div');
|
||
appendText(label, 'strong', routeName(route));
|
||
appendText(label, 'small', routeSource(route) === 'local' ? ns.I18n.t("app.draft") : ns.I18n.t("editor.published-route"));
|
||
if (routeSource(route) === 'local' && route.submissionStatus) {
|
||
appendText(label, 'small', ns.I18n.t(`editor.${route.submissionStatus}`));
|
||
}
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = classes.iconRow;
|
||
actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-route'));
|
||
if (ns.Groups?.shareButton) actions.appendChild(ns.Groups.shareButton('share-route'));
|
||
if (routeSource(route) === 'local') {
|
||
actions.appendChild(iconButton(ns.I18n.t("editor.delete-route"), '×', 'delete-route', false, true));
|
||
}
|
||
item.append(label, actions);
|
||
return item;
|
||
}
|
||
|
||
function routeGroup(title) {
|
||
const item = document.createElement('li');
|
||
item.className = classes.routeGroup;
|
||
item.textContent = title;
|
||
return item;
|
||
}
|
||
|
||
function poiItem(poi, index) {
|
||
const item = document.createElement('li');
|
||
item.className = classes.poiItem;
|
||
item.dataset.poiId = poiId(poi);
|
||
const title = poiTitle(poi);
|
||
const label = document.createElement('div');
|
||
appendText(label, 'strong', title);
|
||
appendText(label, 'small', ns.I18n.t("editor.station-number", { value1: index + 1 }));
|
||
const actions = document.createElement('div');
|
||
actions.className = classes.iconRow;
|
||
actions.appendChild(iconButton(ns.I18n.t("editor.station.up", { value1: title }), '↑', 'move-poi-up', index === 0));
|
||
actions.appendChild(iconButton(ns.I18n.t("editor.station.down", { value1: title }), '↓', 'move-poi-down', index === state.pois.length - 1));
|
||
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
|
||
actions.appendChild(iconButton(ns.I18n.t("editor.delete-item", { value1: title }), '×', 'delete-poi', false, true));
|
||
item.append(label, actions);
|
||
return item;
|
||
}
|
||
|
||
function renderRouteGroup(list, title, routes, emptyText) {
|
||
list.appendChild(routeGroup(title));
|
||
if (!routes.length) {
|
||
const item = document.createElement('li');
|
||
item.className = classes.routeItem;
|
||
appendText(item, 'span', emptyText);
|
||
list.appendChild(item);
|
||
return;
|
||
}
|
||
routes.forEach(route => list.appendChild(routeItem(route)));
|
||
}
|
||
|
||
function renderRouteList() {
|
||
const list = byId(ids.routeList);
|
||
if (!list) return;
|
||
list.textContent = '';
|
||
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
|
||
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
|
||
renderRouteGroup(list, ns.I18n.t("editor.drafts"), localRoutes, ns.I18n.t("editor.no-draft"));
|
||
renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route"));
|
||
}
|
||
|
||
function renderPoiList() {
|
||
const list = byId(ids.poiList);
|
||
if (!list) return;
|
||
list.textContent = '';
|
||
if (!state.pois.length) {
|
||
const item = document.createElement('li');
|
||
item.className = classes.poiItem;
|
||
appendText(item, 'span', ns.I18n.t("editor.no-stations"));
|
||
list.appendChild(item);
|
||
return;
|
||
}
|
||
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
||
}
|
||
|
||
function localRouteSummaries() {
|
||
if (!ns.LocalRoutes) return [];
|
||
return ns.LocalRoutes.list().map(routePackage => ({
|
||
...routePackage.route,
|
||
id: routePackage.id,
|
||
_source: 'local'
|
||
}));
|
||
}
|
||
|
||
let routeListGeneration = 0;
|
||
let shareInProgress = false;
|
||
|
||
async function loadRoutes() {
|
||
const requestGeneration = ++routeListGeneration;
|
||
state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')];
|
||
renderRouteList();
|
||
setStatus('');
|
||
// Local drafts are usable even while this request is waiting for a connection.
|
||
refreshPublicRoutes(requestGeneration);
|
||
refreshSubmittedRoutes(requestGeneration);
|
||
}
|
||
|
||
async function refreshSubmittedRoutes(requestGeneration) {
|
||
const submitted = localRouteSummaries().filter(route => route.serverRouteId);
|
||
await Promise.allSettled(submitted.map(async route => {
|
||
const existing = await findSubmittedRoute(route.serverRouteId);
|
||
if (requestGeneration !== routeListGeneration) return;
|
||
const latest = ns.LocalRoutes.get(route.id);
|
||
if (!latest || String(latest.route.serverRouteId) !== String(route.serverRouteId)) return;
|
||
const submissionStatus = submittedStatus(existing);
|
||
if ((latest.route.submissionStatus || null) === submissionStatus) return;
|
||
latest.route.submissionStatus = submissionStatus;
|
||
const saved = ns.LocalRoutes.save(latest);
|
||
if (state.currentSource === 'local' && routeId(state.currentRoute) === route.id) {
|
||
state.currentRoute.submissionStatus = submissionStatus;
|
||
renderLocalSubmissionStatus(saved.route);
|
||
}
|
||
}));
|
||
if (requestGeneration !== routeListGeneration) return;
|
||
state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')];
|
||
renderRouteList();
|
||
}
|
||
|
||
function renderLocalSubmissionStatus(route) {
|
||
const uploaded = Boolean(route?.serverRouteId);
|
||
const uploadButton = byId(ids.routeUpload);
|
||
if (uploadButton) uploadButton.textContent = ns.I18n.t(uploaded ? 'editor.submitted' : 'editor.submit');
|
||
const hint = uploaded ? 'editor.submitted-hint' : 'editor.draft-hint';
|
||
byId(ids.routeActivated).textContent = ns.I18n.t(route?.submissionStatus ? `editor.${route.submissionStatus}-hint` : hint);
|
||
}
|
||
|
||
async function refreshPublicRoutes(requestGeneration) {
|
||
const listIsVisible = () => byId(ids.dialog)?.open && !byId(ids.routeListView)?.hidden;
|
||
try {
|
||
const payload = await readJson('/routes?activatedOnly=true');
|
||
if (requestGeneration !== routeListGeneration) return;
|
||
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
|
||
state.routes = [...localRouteSummaries(), ...serverRoutes];
|
||
renderRouteList();
|
||
} catch {
|
||
if (requestGeneration === routeListGeneration && listIsVisible()) {
|
||
setStatus(ns.I18n.t('editor.public-unavailable'));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openEditor() {
|
||
const dialog = byId(ids.dialog);
|
||
if (!dialog) return;
|
||
if (!dialog.open) {
|
||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||
else dialog.setAttribute('open', '');
|
||
}
|
||
setDialogExpanded(true);
|
||
showListView();
|
||
await guarded(loadRoutes);
|
||
ns.Groups?.refresh?.();
|
||
}
|
||
|
||
async function openNewLocalRoute() {
|
||
await openEditor();
|
||
newRoute();
|
||
}
|
||
|
||
function closeEditor() {
|
||
if (pointSaveInProgress) return;
|
||
ns.GroupPoints?.release();
|
||
releasePoiPreview();
|
||
ns.RouteRecorder?.endEditing();
|
||
const dialog = byId(ids.dialog);
|
||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||
else if (dialog?.open) dialog.removeAttribute('open');
|
||
setDialogExpanded(false);
|
||
setStatus('');
|
||
}
|
||
|
||
function routeFromForm() {
|
||
return {
|
||
description: byId(ids.routeDescription)?.value.trim() || '',
|
||
name: byId(ids.routeName)?.value.trim() || ''
|
||
};
|
||
}
|
||
|
||
function poiFromForm() {
|
||
return {
|
||
description: byId(ids.poiDescription)?.value.trim() || '',
|
||
lat: numberOrNull(byId(ids.poiLat)?.value),
|
||
lon: numberOrNull(byId(ids.poiLon)?.value),
|
||
triggerRadiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||
sequence: state.currentPoi?.sequence ?? state.pois.length,
|
||
title: byId(ids.poiTitle)?.value.trim() || ''
|
||
};
|
||
}
|
||
|
||
function fillRouteForm(route) {
|
||
setHidden(byId('editor-path-positions'), state.currentSource !== 'local');
|
||
setHidden(byId('editor-recording-options'), state.currentSource !== 'local');
|
||
if (state.currentSource === 'local') ns.RouteRecorder?.renderPoints(route.gpx || ns.LocalRoutes.emptyGpx(route.name));
|
||
byId(ids.routeId).value = routeId(route);
|
||
byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor.untitled-route") ? '' : routeName(route);
|
||
byId(ids.routeDescription).value = text(route?.description);
|
||
byId(ids.routeGpx).value = '';
|
||
setHidden(byId(ids.routeGpxGroup), state.currentSource !== 'server');
|
||
|
||
const uploadButton = byId(ids.routeUpload);
|
||
if (state.currentSource === 'local') {
|
||
setHidden(uploadButton, false);
|
||
setDisabled(uploadButton, false);
|
||
renderLocalSubmissionStatus(route);
|
||
} else {
|
||
setHidden(uploadButton, true);
|
||
setDisabled(uploadButton, true);
|
||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||
? ns.I18n.t("editor.read-only-hint")
|
||
: ns.I18n.t("editor.unpublished-hint");
|
||
}
|
||
}
|
||
|
||
function objectUrlFor(file) {
|
||
if (!file || typeof globalThis.URL?.createObjectURL !== 'function') return null;
|
||
return globalThis.URL.createObjectURL(file);
|
||
}
|
||
|
||
function releasePendingPreview(pending) {
|
||
const url = pending?.previewUrl;
|
||
if (!url?.startsWith('blob:') || typeof globalThis.URL?.revokeObjectURL !== 'function') return;
|
||
globalThis.URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function resetPendingMedia() {
|
||
state.pendingAudioFile = null;
|
||
state.pendingAudioHandle = null;
|
||
state.pendingImages.forEach(releasePendingPreview);
|
||
state.pendingImages = [];
|
||
state.selectedMediaKey = null;
|
||
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
|
||
if (byId(ids.poiImages)) byId(ids.poiImages).value = '';
|
||
}
|
||
|
||
function existingAudioUrl(poi) {
|
||
return poi?.audio?.url || poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
|
||
}
|
||
|
||
function imagePreviewUrl(image) {
|
||
return image?.dataUrl || image?.url || null;
|
||
}
|
||
|
||
function mediaPreviewCard({ key, kind, label, previewUrl = null, source, mediaId = '', mediaIndex = '' }) {
|
||
const card = document.createElement('div');
|
||
card.className = classes.mediaPreviewCard;
|
||
card.dataset.selected = String(state.selectedMediaKey === key);
|
||
|
||
const select = document.createElement('button');
|
||
select.className = classes.mediaPreviewSelect;
|
||
select.dataset.editorAction = 'select-media';
|
||
select.dataset.mediaKey = key;
|
||
select.type = 'button';
|
||
select.setAttribute('aria-expanded', String(state.selectedMediaKey === key));
|
||
select.setAttribute('aria-label', ns.I18n.t("editor.remove-media-label", { value1: label, value2: state.selectedMediaKey === key ? 'ausblenden' : 'anzeigen' }));
|
||
|
||
if (kind === 'image' && previewUrl) {
|
||
const image = document.createElement('img');
|
||
image.className = classes.mediaPreviewImage;
|
||
image.src = previewUrl;
|
||
image.alt = '';
|
||
select.appendChild(image);
|
||
} else {
|
||
const mediaSymbol = document.createElement('span');
|
||
mediaSymbol.className = classes.mediaPreviewAudio;
|
||
mediaSymbol.setAttribute('aria-hidden', 'true');
|
||
mediaSymbol.textContent = kind === 'image' ? '▧' : '♪';
|
||
select.appendChild(mediaSymbol);
|
||
}
|
||
appendText(select, 'small', label);
|
||
|
||
const remove = document.createElement('button');
|
||
remove.className = classes.mediaRemove;
|
||
remove.dataset.editorAction = 'remove-media';
|
||
remove.dataset.mediaId = text(mediaId);
|
||
remove.dataset.mediaIndex = text(mediaIndex);
|
||
remove.dataset.mediaKey = key;
|
||
remove.dataset.mediaKind = kind;
|
||
remove.dataset.mediaSource = source;
|
||
remove.hidden = state.selectedMediaKey !== key;
|
||
remove.type = 'button';
|
||
remove.textContent = '−';
|
||
remove.title = `${label} entfernen`;
|
||
remove.setAttribute('aria-label', `${label} entfernen`);
|
||
|
||
card.append(select, remove);
|
||
return card;
|
||
}
|
||
|
||
function renderPoiMediaPreview(poi = state.currentPoi) {
|
||
const preview = byId(ids.poiMediaPreview);
|
||
if (!preview) return;
|
||
preview.textContent = '';
|
||
|
||
if (poi?.audio || existingAudioUrl(poi)) {
|
||
preview.appendChild(mediaPreviewCard({
|
||
key: 'existing-audio',
|
||
kind: 'audio',
|
||
label: poi?.audio?.name || 'Tonaufnahme',
|
||
source: 'existing'
|
||
}));
|
||
}
|
||
|
||
if (state.pendingAudioFile) {
|
||
preview.appendChild(mediaPreviewCard({
|
||
key: 'pending-audio',
|
||
kind: 'audio',
|
||
label: state.pendingAudioFile.name || ns.I18n.t("editor.new-audio"),
|
||
source: 'pending'
|
||
}));
|
||
}
|
||
|
||
const images = Array.isArray(poi?.images) ? poi.images : [];
|
||
images.forEach((image, index) => {
|
||
preview.appendChild(mediaPreviewCard({
|
||
key: `existing-image-${image.id ?? index}`,
|
||
kind: 'image',
|
||
label: image.name || image.caption || ns.I18n.t("editor.image-number", { value1: index + 1 }),
|
||
mediaId: image.id ?? '',
|
||
mediaIndex: index,
|
||
previewUrl: imagePreviewUrl(image),
|
||
source: 'existing'
|
||
}));
|
||
});
|
||
|
||
state.pendingImages.forEach((pending, index) => {
|
||
preview.appendChild(mediaPreviewCard({
|
||
key: pending.key,
|
||
kind: 'image',
|
||
label: pending.file?.name || ns.I18n.t("editor.new-image", { value1: index + 1 }),
|
||
mediaIndex: index,
|
||
previewUrl: pending.previewUrl || null,
|
||
source: 'pending'
|
||
}));
|
||
});
|
||
}
|
||
|
||
function updatePoiMediaStatus(poi = state.currentPoi) {
|
||
const status = byId(ids.poiMediaStatus);
|
||
if (!status) return;
|
||
const existingAudio = poi?.audio || existingAudioUrl(poi) ? 1 : 0;
|
||
const existingImages = Array.isArray(poi?.images) ? poi.images.length : 0;
|
||
const audioCount = existingAudio + (state.pendingAudioFile ? 1 : 0);
|
||
const imageCount = existingImages + state.pendingImages.length;
|
||
const parts = [];
|
||
|
||
if (audioCount) parts.push(ns.I18n.t('media.audio.count', { count: audioCount }));
|
||
if (imageCount) parts.push(ns.I18n.t('media.image.count', { count: imageCount }));
|
||
|
||
status.textContent = parts.length
|
||
? ns.I18n.t("editor.remove-media-hint", { value1: parts.join(' · ') })
|
||
: ns.I18n.t("editor.no-media");
|
||
}
|
||
|
||
function openFilePicker(inputId) {
|
||
const input = byId(inputId);
|
||
if (input && typeof input.click === 'function') input.click();
|
||
}
|
||
|
||
function pickerOptions(kind) {
|
||
if (kind === 'audio') {
|
||
return {
|
||
multiple: false,
|
||
types: [{ description: ns.I18n.t("editor.audio-section"), accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }]
|
||
};
|
||
}
|
||
return {
|
||
multiple: true,
|
||
types: [{ description: ns.I18n.t("editor.image-section"), accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
|
||
};
|
||
}
|
||
|
||
async function pickWithFileSystemAccess(kind) {
|
||
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') return false;
|
||
let handles;
|
||
try {
|
||
handles = await globalThis.showOpenFilePicker(pickerOptions(kind));
|
||
} catch (error) {
|
||
return error?.name === 'AbortError';
|
||
}
|
||
|
||
if (kind === 'audio') {
|
||
const handle = handles?.[0];
|
||
if (!handle) return true;
|
||
state.pendingAudioHandle = handle;
|
||
state.pendingAudioFile = await handle.getFile();
|
||
} else {
|
||
for (const handle of handles || []) {
|
||
const file = await handle.getFile();
|
||
state.pendingImages.push({
|
||
file,
|
||
handle,
|
||
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
|
||
previewUrl: objectUrlFor(file)
|
||
});
|
||
}
|
||
}
|
||
state.selectedMediaKey = null;
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
return true;
|
||
}
|
||
|
||
function chooseMedia(kind, inputId) {
|
||
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') {
|
||
openFilePicker(inputId);
|
||
return null;
|
||
}
|
||
return pickWithFileSystemAccess(kind).then(handled => {
|
||
if (!handled) openFilePicker(inputId);
|
||
});
|
||
}
|
||
|
||
function fillPoiForm(poi) {
|
||
resetPendingMedia();
|
||
byId(ids.poiId).value = poiId(poi);
|
||
byId(ids.poiTitle).value = poiTitle(poi) === ns.I18n.t("editor.untitled-station") ? '' : poiTitle(poi);
|
||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||
byId(ids.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
|
||
setHidden(byId(ids.poiDelete), !poiId(poi));
|
||
renderPoiMediaPreview(poi);
|
||
updatePoiMediaStatus(poi);
|
||
}
|
||
|
||
function handleAudioSelection() {
|
||
state.pendingAudioHandle = null;
|
||
state.pendingAudioFile = byId(ids.poiAudio)?.files?.[0] || null;
|
||
state.selectedMediaKey = null;
|
||
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
}
|
||
|
||
async function handleImageSelection() {
|
||
const files = Array.from(byId(ids.poiImages)?.files || []);
|
||
for (const file of files) {
|
||
state.pendingImages.push({
|
||
file,
|
||
handle: null,
|
||
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
|
||
previewUrl: objectUrlFor(file)
|
||
});
|
||
}
|
||
state.selectedMediaKey = null;
|
||
if (byId(ids.poiImages)) byId(ids.poiImages).value = '';
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
}
|
||
|
||
async function loadRoute(route) {
|
||
const id = routeId(route);
|
||
if (!id) return;
|
||
if (state.currentRoute && (routeSource(route) !== state.currentSource || routeId(state.currentRoute) !== id)) {
|
||
ns.RouteRecorder?.endEditing();
|
||
}
|
||
setStatus(ns.I18n.t("editor.route.loading"));
|
||
state.currentSource = routeSource(route);
|
||
|
||
if (state.currentSource === 'local') {
|
||
const routePackage = ns.LocalRoutes.get(id);
|
||
if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing"));
|
||
state.currentRoute = {
|
||
...routePackage.route,
|
||
id: routePackage.id,
|
||
gpx: routePackage.gpx,
|
||
_source: 'local'
|
||
};
|
||
if (byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(id);
|
||
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||
} else {
|
||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||
const detail = payload.route || payload;
|
||
state.currentRoute = { ...detail, _source: state.currentSource };
|
||
state.pois = poiArray(detail);
|
||
}
|
||
|
||
state.currentPoi = null;
|
||
fillRouteForm(state.currentRoute);
|
||
renderPoiList();
|
||
showRouteView();
|
||
setStatus('');
|
||
}
|
||
|
||
function newRoute() {
|
||
ns.RouteRecorder?.endEditing();
|
||
state.currentSource = 'local';
|
||
state.currentRoute = {
|
||
id: '',
|
||
name: '',
|
||
description: '',
|
||
gpx: ns.LocalRoutes.emptyGpx(ns.I18n.t("editor.new-route")),
|
||
_source: 'local'
|
||
};
|
||
state.currentPoi = null;
|
||
state.pois = [];
|
||
fillRouteForm(state.currentRoute);
|
||
renderPoiList();
|
||
showRouteView();
|
||
setStatus(ns.I18n.t("editor.draft-status"));
|
||
}
|
||
|
||
function formDataFromRoute(values) {
|
||
const data = new FormData();
|
||
const file = byId(ids.routeGpx)?.files?.[0];
|
||
data.append('name', values.name);
|
||
data.append('description', values.description);
|
||
data.append('creator_id', state.currentRoute.creator_id || ns.LocalRoutes.creatorId());
|
||
if (file) data.append('gpx', file);
|
||
return data;
|
||
}
|
||
|
||
async function saveLocalRoute(values) {
|
||
const file = byId(ids.routeGpx)?.files?.[0];
|
||
let gpx = ns.LocalRoutes.get?.(routeId(state.currentRoute))?.gpx || state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
|
||
if (file) {
|
||
gpx = await ns.LocalRoutes.readFileAsText(file);
|
||
const importedPois = ns.LocalRoutes.parseWaypoints(gpx);
|
||
if (!state.pois.length && importedPois.length) state.pois = importedPois;
|
||
}
|
||
|
||
gpx = ns.RouteRecorder?.editGpx(gpx, values.name) ?? gpx;
|
||
const isNew = !routeId(state.currentRoute);
|
||
const id = routeId(state.currentRoute) || ns.LocalRoutes.createRouteId();
|
||
const saved = ns.LocalRoutes.save({
|
||
id,
|
||
gpx,
|
||
route: {
|
||
...state.currentRoute,
|
||
id,
|
||
name: values.name,
|
||
description: values.description
|
||
},
|
||
pois: state.pois
|
||
});
|
||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||
state.currentSource = 'local';
|
||
if (isNew && byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(saved.id);
|
||
state.pois = saved.pois;
|
||
await loadRoutes();
|
||
await loadRoute({ id: saved.id, _source: 'local' });
|
||
setStatus(ns.I18n.t("editor.saved"));
|
||
}
|
||
|
||
async function saveServerRoute(values) {
|
||
const id = routeId(state.currentRoute);
|
||
if (!id) throw new Error(ns.I18n.t("editor.error.create-locally"));
|
||
const file = byId(ids.routeGpx)?.files?.[0];
|
||
values = { ...values, creator_id: state.currentRoute.creator_id || ns.LocalRoutes.creatorId() };
|
||
const path = `/routes/${encodeURIComponent(id)}`;
|
||
const payload = file
|
||
? await sendForm(path, 'PUT', formDataFromRoute(values))
|
||
: await sendJson(path, 'PUT', values);
|
||
const saved = payload.route || payload;
|
||
state.currentRoute = { ...saved, _source: state.currentSource };
|
||
await loadRoutes();
|
||
await loadRoute({ id, _source: state.currentSource });
|
||
setStatus(ns.I18n.t("editor.saved"));
|
||
}
|
||
|
||
async function saveRoute() {
|
||
const values = routeFromForm();
|
||
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
|
||
if (state.currentSource === 'local') await saveLocalRoute(values);
|
||
else await saveServerRoute(values);
|
||
}
|
||
|
||
function safeGpxFilename(name) {
|
||
let base = text(name || 'wegwichtel-route')
|
||
.normalize('NFKD')
|
||
.replaceAll(/[^a-zA-Z0-9_-]+/g, '-');
|
||
|
||
while (base.startsWith('-')) base = base.slice(1);
|
||
while (base.endsWith('-')) base = base.slice(0, -1);
|
||
|
||
base = base.slice(0, 80);
|
||
return `${base || 'wegwichtel-route'}.gpx`;
|
||
}
|
||
|
||
function localRouteUploadForm(routePackage) {
|
||
const data = new FormData();
|
||
data.append('name', routePackage.route?.name || ns.I18n.t("editor.untitled-route"));
|
||
data.append('description', routePackage.route?.description || '');
|
||
data.append('creator_id', routePackage.route?.creator_id || ns.LocalRoutes.creatorId());
|
||
const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
|
||
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
|
||
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
|
||
return data;
|
||
}
|
||
|
||
async function storedMediaBlob(media) {
|
||
const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true });
|
||
if (!blob) {
|
||
throw new Error(ns.I18n.t("editor.error.send-file", { value1: media?.name || '' }).trim());
|
||
}
|
||
return blob;
|
||
}
|
||
|
||
async function uploadStoredMedia(path, fieldName, media, fallbackName, method = 'POST') {
|
||
const blob = await storedMediaBlob(media);
|
||
const data = new FormData();
|
||
data.append(fieldName, blob, media.name || fallbackName);
|
||
if (fieldName === 'picture') data.append('caption', media.caption || '');
|
||
return sendForm(path, method, data);
|
||
}
|
||
|
||
function checkpointLocalUpload(routePackage) {
|
||
const latest = ns.LocalRoutes.get(routePackage.id);
|
||
// GPS samples may arrive while media requests are pending.
|
||
if (latest) routePackage.gpx = latest.gpx;
|
||
return ns.LocalRoutes.save(routePackage);
|
||
}
|
||
|
||
function submittedStatus(existing) {
|
||
if (!existing) return 'removed';
|
||
return existing.status === 'deleted' ? 'rejected' : null;
|
||
}
|
||
|
||
async function findSubmittedRoute(id) {
|
||
try {
|
||
const payload = await readJson(`/routes/${encodeURIComponent(id)}?includeDeleted=true`);
|
||
return payload.route || payload;
|
||
} catch (error) {
|
||
if (error.status === 404) return null;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function clearSubmittedReferences(routePackage) {
|
||
routePackage.route.serverRouteId = null;
|
||
routePackage.route.uploadedAt = null;
|
||
for (const poi of routePackage.pois || []) {
|
||
delete poi.serverPoiId;
|
||
delete poi.serverAudio;
|
||
for (const image of poi.images || []) delete image.serverPictureId;
|
||
}
|
||
checkpointLocalUpload(routePackage);
|
||
}
|
||
|
||
async function prepareServerRoute(routePackage) {
|
||
const id = routePackage.route.serverRouteId;
|
||
const replacedSubmission = Boolean(id || routePackage.route.submissionStatus);
|
||
if (id) {
|
||
const existing = await findSubmittedRoute(id);
|
||
if (!existing || existing.status === 'deleted') {
|
||
// Preserve the old trash entry; submit a new draft with fresh references.
|
||
routePackage.route.submissionStatus = submittedStatus(existing);
|
||
clearSubmittedReferences(routePackage);
|
||
} else {
|
||
if (existing.activatedAt || existing.activated_at) {
|
||
throw new Error(ns.I18n.t("editor.read-only-hint"));
|
||
}
|
||
await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage));
|
||
return { id, previousPois: existing.pois || [] };
|
||
}
|
||
}
|
||
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
|
||
const createdId = routeId(payload.route || payload);
|
||
if (!createdId) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||
routePackage.route.serverRouteId = createdId;
|
||
routePackage.route.submissionStatus = null;
|
||
checkpointLocalUpload(routePackage);
|
||
return { id: createdId, previousPois: [], recreated: replacedSubmission };
|
||
}
|
||
|
||
async function syncLocalPoi(base, poi, index, routePackage) {
|
||
const payload = await sendJson(poi.serverPoiId ? `${base}/${poi.serverPoiId}` : base,
|
||
poi.serverPoiId ? 'PUT' : 'POST', {
|
||
description: poi.description || '', lat: poi.lat, lon: poi.lon, sequence: index,
|
||
title: poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }),
|
||
triggerRadiusM: poi.triggerRadiusM || 25
|
||
});
|
||
poi.serverPoiId = poiId(payload.poi || payload);
|
||
if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' }));
|
||
checkpointLocalUpload(routePackage);
|
||
}
|
||
|
||
async function syncLocalAudio(mediaBase, poi, oldPoi, routePackage) {
|
||
if (poi.audio) {
|
||
await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin',
|
||
oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST');
|
||
poi.serverAudio = true;
|
||
checkpointLocalUpload(routePackage);
|
||
} else if (oldPoi?.audioUrl) {
|
||
await sendDelete(`${mediaBase}/audio`);
|
||
poi.serverAudio = false;
|
||
checkpointLocalUpload(routePackage);
|
||
}
|
||
}
|
||
|
||
async function removeMissingServerItems(previous, current, serverIdKey, base) {
|
||
const retained = new Set(current.map(item => String(item[serverIdKey])));
|
||
for (const item of previous) {
|
||
if (!retained.has(String(item.id))) await sendDelete(`${base}/${item.id}`);
|
||
}
|
||
}
|
||
|
||
async function syncLocalPictures(mediaBase, poi, oldPoi, routePackage) {
|
||
const images = poi.images || [];
|
||
for (const [index, image] of images.entries()) {
|
||
const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`;
|
||
const picture = await uploadStoredMedia(path, 'picture', image, `bild-${index + 1}.bin`,
|
||
image.serverPictureId ? 'PUT' : 'POST');
|
||
image.serverPictureId = (picture.picture || picture).id;
|
||
if (!image.serverPictureId) throw new Error(ns.I18n.t("editor.error.send-file", { value1: image.name || '' }));
|
||
checkpointLocalUpload(routePackage);
|
||
}
|
||
await removeMissingServerItems(oldPoi?.images || [], images, 'serverPictureId', `${mediaBase}/pictures`);
|
||
}
|
||
|
||
async function createServerRouteFromLocal(routePackage) {
|
||
const { id, previousPois, recreated } = await prepareServerRoute(routePackage);
|
||
const base = `/routes/${encodeURIComponent(id)}/pois`;
|
||
const orderedPois = [...(routePackage.pois || [])]
|
||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||
for (const [index, poi] of orderedPois.entries()) {
|
||
await syncLocalPoi(base, poi, index, routePackage);
|
||
const mediaBase = `${base}/${poi.serverPoiId}`;
|
||
const oldPoi = previousPois.find(old => String(old.id) === String(poi.serverPoiId));
|
||
await syncLocalAudio(mediaBase, poi, oldPoi, routePackage);
|
||
await syncLocalPictures(mediaBase, poi, oldPoi, routePackage);
|
||
}
|
||
await removeMissingServerItems(previousPois, orderedPois, 'serverPoiId', base);
|
||
return { id, recreated };
|
||
}
|
||
|
||
async function uploadLocalRoute() {
|
||
if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft"));
|
||
const values = routeFromForm();
|
||
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
|
||
const uploadButton = byId(ids.routeUpload);
|
||
setDisabled(uploadButton, true);
|
||
try {
|
||
await saveLocalRoute(values);
|
||
setStatus(ns.I18n.t("editor.submitting"));
|
||
const localId = routeId(state.currentRoute);
|
||
const routePackage = ns.LocalRoutes.get(localId);
|
||
if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing"));
|
||
|
||
const { id: serverRouteId, recreated } = await createServerRouteFromLocal(routePackage);
|
||
const uploadedAt = new Date().toISOString();
|
||
const saved = ns.LocalRoutes.save({
|
||
...routePackage,
|
||
route: {
|
||
...routePackage.route,
|
||
serverRouteId,
|
||
uploadedAt
|
||
}
|
||
});
|
||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||
state.pois = saved.pois;
|
||
fillRouteForm(state.currentRoute);
|
||
await loadRoutes();
|
||
setStatus(ns.I18n.t(recreated ? 'editor.resubmit-success' : 'editor.submit-success'));
|
||
} finally {
|
||
// Keep acknowledged server IDs even if a later media request failed.
|
||
const latest = ns.LocalRoutes.get(routeId(state.currentRoute));
|
||
if (latest) {
|
||
state.currentRoute = { ...latest.route, id: latest.id, gpx: latest.gpx, _source: 'local' };
|
||
state.pois = latest.pois;
|
||
fillRouteForm(state.currentRoute);
|
||
}
|
||
setDisabled(uploadButton, false);
|
||
}
|
||
}
|
||
|
||
function currentPosition() {
|
||
if (ns.RouteRecorder?.routeId === routeId(state.currentRoute)) return ns.RouteRecorder.position;
|
||
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
|
||
}
|
||
|
||
function newPoi() {
|
||
ns.GroupPoints?.release();
|
||
releasePoiPreview();
|
||
const position = currentPosition();
|
||
const lat = position?.lat ?? null;
|
||
const lon = position?.lon ?? null;
|
||
|
||
const accuracy = Number(position?.accuracy);
|
||
state.currentPoi = null;
|
||
state.groupDeletions = [];
|
||
state.groupDeletePoint = false;
|
||
const identifier = state.currentSource === 'local' ? nextLocalPoiId() : null;
|
||
fillPoiForm({
|
||
...(identifier == null ? {} : { id: identifier }),
|
||
lat,
|
||
lon,
|
||
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
|
||
});
|
||
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.add-station");
|
||
showPoiView();
|
||
}
|
||
|
||
async function editPoi(poi) {
|
||
if (!poi) return;
|
||
releasePoiPreview();
|
||
let draft;
|
||
if (state.currentSource === 'group' && ns.GroupPoints) {
|
||
const opened = await ns.GroupPoints.begin(routeId(state.currentRoute), poi);
|
||
if (!opened) return;
|
||
poi = opened.poi;
|
||
draft = opened.draft;
|
||
}
|
||
state.groupDeletions = draft?.media?.deletions || [];
|
||
state.groupDeletePoint = Boolean(draft?.media?.deletePoint);
|
||
state.currentPoi = poi;
|
||
fillPoiForm(draft ? { ...poi, ...draft.values } : poi);
|
||
if (draft) {
|
||
state.pendingAudioFile = draft.media?.audio || null;
|
||
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
|
||
const hiddenImages = new Set(state.groupDeletions.map(item => item.path));
|
||
state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.has(`pictures/${image.id}`)),
|
||
...(hiddenImages.has('audio') ? { audioUrl: null } : {}) };
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored'));
|
||
}
|
||
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
|
||
showPoiView();
|
||
if (state.currentSource !== 'local') return true;
|
||
const requestGeneration = poiMediaGeneration;
|
||
const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true });
|
||
if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) {
|
||
ns.LocalRoutes.releasePoiMedia(hydrated);
|
||
return;
|
||
}
|
||
if (!hydrated) return;
|
||
previewPoi = hydrated;
|
||
state.currentPoi = hydrated;
|
||
// Preserve any text edits and newly selected files while existing previews load.
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
}
|
||
|
||
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||
const routeIdentifier = routeId(state.currentRoute);
|
||
if (!routeIdentifier) throw new Error(ns.I18n.t("editor.error.save-route-first"));
|
||
|
||
const storedNow = [];
|
||
const replacedAfterSave = [];
|
||
let audio = existingPoi?.audio || null;
|
||
|
||
if (state.pendingAudioFile) {
|
||
const mediaId = `${poiIdentifier}-audio`;
|
||
const storedAudio = await ns.LocalRoutes.storeMediaFile(
|
||
routeIdentifier,
|
||
poiIdentifier,
|
||
'audio',
|
||
mediaId,
|
||
state.pendingAudioFile,
|
||
state.pendingAudioHandle
|
||
);
|
||
storedNow.push(storedAudio);
|
||
if (audio) replacedAfterSave.push(audio);
|
||
audio = storedAudio;
|
||
}
|
||
|
||
const images = [...(existingPoi?.images || [])];
|
||
for (const pending of state.pendingImages) {
|
||
const mediaId = `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`;
|
||
const image = await ns.LocalRoutes.storeMediaFile(
|
||
routeIdentifier,
|
||
poiIdentifier,
|
||
'image',
|
||
mediaId,
|
||
pending.file,
|
||
pending.handle
|
||
);
|
||
images.push({ ...image, caption: '' });
|
||
storedNow.push(image);
|
||
}
|
||
return { audio, images, storedNow, replacedAfterSave };
|
||
}
|
||
|
||
async function persistLocalCurrentRoute() {
|
||
const id = routeId(state.currentRoute);
|
||
if (!id) throw new Error(ns.I18n.t("editor.error.save-route-first"));
|
||
const currentPoiId = poiId(state.currentPoi);
|
||
const saved = ns.LocalRoutes.save({
|
||
id,
|
||
gpx: ns.LocalRoutes.get?.(routeId(state.currentRoute))?.gpx || state.currentRoute.gpx,
|
||
route: state.currentRoute,
|
||
pois: state.pois
|
||
});
|
||
const previousPoi = state.currentPoi;
|
||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||
state.pois = saved.pois;
|
||
state.currentPoi = currentPoiId
|
||
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
|
||
: null;
|
||
if (state.currentPoi && previousPoi) {
|
||
state.currentPoi = { ...state.currentPoi, audio: previousPoi.audio, images: previousPoi.images };
|
||
}
|
||
return saved;
|
||
}
|
||
|
||
async function saveLocalPoi(values) {
|
||
|
||
const currentId = numericPoiId(byId(ids.poiId).value);
|
||
const identifier = currentId ?? nextLocalPoiId();
|
||
byId(ids.poiId).value = String(identifier);
|
||
const existing = state.pois.find(poi => numericPoiId(poi?.id ?? poi?.poiId) === identifier) || state.currentPoi;
|
||
const media = await mediaFromLocalForm(existing, identifier);
|
||
const savedPoi = {
|
||
...existing,
|
||
...values,
|
||
audio: media.audio,
|
||
images: media.images,
|
||
id: identifier,
|
||
sequence: existing?.sequence ?? state.pois.length
|
||
};
|
||
const index = state.pois.findIndex(poi => numericPoiId(poi?.id ?? poi?.poiId) === identifier);
|
||
if (index >= 0) state.pois[index] = savedPoi;
|
||
else state.pois.push(savedPoi);
|
||
|
||
try {
|
||
await persistLocalCurrentRoute();
|
||
} catch (error) {
|
||
await Promise.allSettled(media.storedNow.map(ns.LocalRoutes.removeMedia));
|
||
throw error;
|
||
}
|
||
await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia));
|
||
resetPendingMedia();
|
||
setStatus(ns.I18n.t("editor.station-saved-with-media"));
|
||
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
|
||
}
|
||
|
||
async function uploadOne(path, fieldName, file, method = 'POST') {
|
||
const data = new FormData();
|
||
data.append(fieldName, file);
|
||
return sendForm(path, method, data);
|
||
}
|
||
|
||
async function uploadPoiMedia(currentRouteId, currentPoiId) {
|
||
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||
if (state.pendingAudioFile) {
|
||
const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST';
|
||
await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method);
|
||
if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); }
|
||
}
|
||
for (const pending of state.pendingImages) {
|
||
await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
|
||
if (state.currentSource === 'group') {
|
||
state.pendingImages = state.pendingImages.filter(item => item !== pending);
|
||
releasePendingPreview(pending);
|
||
await rememberGroupPoint(poiFromForm(), currentPoiId);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function rememberGroupPoint(values, identifier = byId(ids.poiId).value || 'new') {
|
||
return ns.GroupPoints.remember(state.currentRoute, identifier, values, {
|
||
audio: state.pendingAudioFile, images: state.pendingImages.map(item => item.file), deletions: state.groupDeletions, deletePoint: state.groupDeletePoint
|
||
});
|
||
}
|
||
|
||
async function prepareGroupPoint(values, currentPoiId) {
|
||
const draft = await rememberGroupPoint(values);
|
||
if (!currentPoiId) return true;
|
||
try {
|
||
const prepared = await ns.GroupPoints.prepare(draft);
|
||
if (prepared) {
|
||
state.groupDeletions = draft.media.deletions;
|
||
state.currentPoi = prepared.poi;
|
||
return true;
|
||
}
|
||
} catch { /* The complete intent was persisted before trying to acquire the point. */ }
|
||
setStatus(ns.I18n.t('point-locks.saved-device'));
|
||
return false;
|
||
}
|
||
|
||
async function saveGroupPointActions(currentRouteId, currentPoiId, saved, values) {
|
||
const savedPoiId = poiId(saved) || currentPoiId;
|
||
if (!currentPoiId) {
|
||
byId(ids.poiId).value = savedPoiId;
|
||
state.currentPoi = saved;
|
||
// Persist the acknowledged ID before any further network request.
|
||
await rememberGroupPoint(values, savedPoiId);
|
||
await ns.GroupPoints.remove(currentRouteId, 'new');
|
||
await ns.GroupPoints.acceptNew(currentRouteId, saved);
|
||
}
|
||
for (const deletion of state.groupDeletions) {
|
||
await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`);
|
||
state.groupDeletions = state.groupDeletions.filter(item => item !== deletion);
|
||
await rememberGroupPoint(values, savedPoiId);
|
||
}
|
||
}
|
||
|
||
async function saveServerPoi(values) {
|
||
if (state.currentSource === 'group' && state.groupDeletePoint) return deleteCurrentPoi();
|
||
const currentRouteId = routeId(state.currentRoute);
|
||
if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first"));
|
||
const currentPoiId = byId(ids.poiId).value;
|
||
if (state.currentSource === 'group' && ns.GroupPoints && !await prepareGroupPoint(values, currentPoiId)) return;
|
||
const path = currentPoiId
|
||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||
const method = currentPoiId ? 'PUT' : 'POST';
|
||
const payload = await sendJson(path, method, values);
|
||
const saved = payload.poi || payload;
|
||
const savedPoiId = poiId(saved) || currentPoiId;
|
||
if (state.currentSource === 'group' && ns.GroupPoints) await saveGroupPointActions(currentRouteId, currentPoiId, saved, values);
|
||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||
if (state.currentSource === 'group' && ns.GroupPoints) await ns.GroupPoints.remove(currentRouteId, savedPoiId);
|
||
setStatus(ns.I18n.t("editor.station-saved"));
|
||
await loadRoute({ id: currentRouteId, _source: state.currentSource });
|
||
}
|
||
|
||
let pointSaveInProgress = false;
|
||
async function savePoi() {
|
||
if (pointSaveInProgress) return;
|
||
pointSaveInProgress = true;
|
||
byId(ids.dialog).inert = true;
|
||
try { await savePoiContents(); } finally { pointSaveInProgress = false; byId(ids.dialog).inert = false; }
|
||
}
|
||
|
||
async function savePoiContents() {
|
||
const values = poiFromForm();
|
||
if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name"));
|
||
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||
else {
|
||
try { await saveServerPoi(values); }
|
||
catch (error) {
|
||
if (state.currentSource !== 'group' || !ns.GroupPoints) throw error;
|
||
// Retain all unacknowledged files and intents, even when the lock changed during upload.
|
||
await rememberGroupPoint(values);
|
||
setStatus(ns.I18n.t('point-locks.saved-device'));
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateCurrentPoi(updatedPoi) {
|
||
const identifier = poiId(updatedPoi);
|
||
const index = state.pois.findIndex(poi => poiId(poi) === identifier);
|
||
if (index >= 0) state.pois[index] = updatedPoi;
|
||
state.currentPoi = updatedPoi;
|
||
}
|
||
|
||
async function removePendingMedia(kind, mediaIndex) {
|
||
if (kind === 'audio') {
|
||
state.pendingAudioFile = null;
|
||
state.pendingAudioHandle = null;
|
||
} else {
|
||
const index = Number.parseInt(mediaIndex, 10);
|
||
if (Number.isInteger(index)) {
|
||
const [removed] = state.pendingImages.splice(index, 1);
|
||
releasePendingPreview(removed);
|
||
}
|
||
}
|
||
state.selectedMediaKey = null;
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
}
|
||
|
||
async function removeExistingLocalMedia(kind, mediaIndex) {
|
||
if (!state.currentPoi) return;
|
||
const updated = { ...state.currentPoi };
|
||
let removedMedia = null;
|
||
if (kind === 'audio') {
|
||
removedMedia = updated.audio;
|
||
updated.audio = null;
|
||
} else {
|
||
const index = Number.parseInt(mediaIndex, 10);
|
||
updated.images = [...(state.currentPoi.images || [])];
|
||
if (Number.isInteger(index)) {
|
||
[removedMedia] = updated.images.splice(index, 1);
|
||
}
|
||
}
|
||
updateCurrentPoi(updated);
|
||
await persistLocalCurrentRoute();
|
||
if (removedMedia) {
|
||
ns.LocalRoutes.releasePoiMedia({ audio: removedMedia });
|
||
await ns.LocalRoutes.removeMedia(removedMedia);
|
||
}
|
||
state.selectedMediaKey = null;
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
|
||
}
|
||
|
||
async function removeExistingServerMedia(kind, mediaId) {
|
||
const currentRouteId = routeId(state.currentRoute);
|
||
const currentPoiId = poiId(state.currentPoi);
|
||
if (!currentRouteId || !currentPoiId) throw new Error(ns.I18n.t("editor.error.station-unsaved"));
|
||
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||
const updated = { ...state.currentPoi };
|
||
|
||
if (kind === 'audio') {
|
||
if (state.currentSource === 'group') state.groupDeletions.push({ path: 'audio' });
|
||
else await sendDelete(`${basePath}/audio`);
|
||
updated.audio = null;
|
||
updated.audioUrl = null;
|
||
updated.audio_path = null;
|
||
} else {
|
||
if (!mediaId) throw new Error(ns.I18n.t("editor.error.remove-image"));
|
||
if (state.currentSource === 'group') state.groupDeletions.push({ path: `pictures/${encodeURIComponent(mediaId)}` });
|
||
else await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
|
||
updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId));
|
||
}
|
||
|
||
updateCurrentPoi(updated);
|
||
state.selectedMediaKey = null;
|
||
renderPoiMediaPreview();
|
||
updatePoiMediaStatus();
|
||
if (state.currentSource === 'group') {
|
||
await rememberGroupPoint(poiFromForm());
|
||
setStatus(ns.I18n.t('point-locks.deletion-staged'));
|
||
} else setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
|
||
}
|
||
|
||
async function removeMedia(control) {
|
||
const kind = control.dataset.mediaKind;
|
||
const source = control.dataset.mediaSource;
|
||
if (source === 'pending') {
|
||
await removePendingMedia(kind, control.dataset.mediaIndex);
|
||
return;
|
||
}
|
||
if (state.currentSource === 'local') {
|
||
await removeExistingLocalMedia(kind, control.dataset.mediaIndex);
|
||
return;
|
||
}
|
||
await removeExistingServerMedia(kind, control.dataset.mediaId);
|
||
}
|
||
|
||
async function mediaPreviewAction(event) {
|
||
const control = event.target.closest('[data-editor-action]');
|
||
const action = control?.dataset.editorAction;
|
||
if (action === 'select-media') {
|
||
const key = control.dataset.mediaKey;
|
||
state.selectedMediaKey = state.selectedMediaKey === key ? null : key;
|
||
renderPoiMediaPreview();
|
||
return;
|
||
}
|
||
if (action === 'remove-media') await removeMedia(control);
|
||
}
|
||
|
||
async function deleteCurrentPoi() {
|
||
const currentRouteId = routeId(state.currentRoute);
|
||
const currentPoiId = poiId(state.currentPoi);
|
||
if (!currentRouteId || !currentPoiId) return;
|
||
|
||
if (state.currentSource === 'local') {
|
||
const removedPoi = state.currentPoi;
|
||
state.pois = state.pois
|
||
.filter(poi => poiId(poi) !== currentPoiId)
|
||
.map((poi, sequence) => ({ ...poi, sequence }));
|
||
state.currentPoi = null;
|
||
await persistLocalCurrentRoute();
|
||
const removedMedia = [removedPoi?.audio, ...(removedPoi?.images || [])].filter(Boolean);
|
||
await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia));
|
||
renderPoiList();
|
||
showRouteView();
|
||
setStatus(ns.I18n.t("editor.station-removed-and-saved"));
|
||
return;
|
||
}
|
||
|
||
if (state.currentSource === 'group' && ns.GroupPoints) {
|
||
state.groupDeletePoint = true;
|
||
const draft = await rememberGroupPoint(poiFromForm());
|
||
try {
|
||
if (!await ns.GroupPoints.prepare(draft)) { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
|
||
await sendDelete(`/routes/${currentRouteId}/pois/${currentPoiId}`);
|
||
await ns.GroupPoints.remove(currentRouteId, currentPoiId);
|
||
} catch { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
|
||
} else await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
|
||
await loadRoute({ id: currentRouteId, _source: state.currentSource });
|
||
setStatus(ns.I18n.t("editor.station-removed"));
|
||
}
|
||
|
||
function reorderPois(index, offset) {
|
||
const target = index + offset;
|
||
if (target < 0 || target >= state.pois.length) return false;
|
||
const reordered = [...state.pois];
|
||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||
state.pois = reordered.map((poi, sequence) => ({ ...poi, sequence }));
|
||
renderPoiList();
|
||
return true;
|
||
}
|
||
|
||
function swapPois(index, offset) {
|
||
if (!reorderPois(index, offset)) return;
|
||
guarded(savePoiOrder);
|
||
}
|
||
|
||
async function saveGroupPoiOrder(currentRouteId) {
|
||
let deferred = false;
|
||
for (const [index, poi] of state.pois.entries()) {
|
||
if (await ns.GroupPoints.read(currentRouteId, poi.id)) { deferred = true; continue; }
|
||
const values = { ...poi, sequence: index };
|
||
try {
|
||
const opened = await ns.GroupPoints.begin(currentRouteId, poi);
|
||
await ns.GroupPoints.remember(state.currentRoute, poi.id, { ...(opened?.poi || values), sequence: index }, { images: [], audio: null, deletions: [] });
|
||
if (!opened) { deferred = true; continue; }
|
||
await sendJson(`/routes/${currentRouteId}/pois/${poi.id}`, 'PUT', { sequence: index, sortOrder: index });
|
||
await ns.GroupPoints.remove(currentRouteId, poi.id);
|
||
} catch {
|
||
await ns.GroupPoints.remember(state.currentRoute, poi.id, values, { images: [], audio: null, deletions: [] });
|
||
deferred = true;
|
||
} finally { await ns.GroupPoints.release(); }
|
||
}
|
||
setStatus(ns.I18n.t(deferred ? 'point-locks.saved-device' : 'editor.order.saved'));
|
||
}
|
||
|
||
async function savePoiOrder() {
|
||
const currentRouteId = routeId(state.currentRoute);
|
||
if (!currentRouteId) return;
|
||
if (state.currentSource === 'local') {
|
||
await persistLocalCurrentRoute();
|
||
setStatus(ns.I18n.t("editor.station-order-saved"));
|
||
return;
|
||
}
|
||
if (state.currentSource === 'group' && ns.GroupPoints) return saveGroupPoiOrder(currentRouteId);
|
||
const jobs = state.pois.map((poi, index) => {
|
||
const currentPoiId = poiId(poi);
|
||
if (!currentPoiId) return null;
|
||
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||
return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index });
|
||
}).filter(Boolean);
|
||
await Promise.all(jobs);
|
||
setStatus(ns.I18n.t("editor.order.saved"));
|
||
}
|
||
|
||
function routeForItem(target) {
|
||
const item = target.closest('[data-route-id]');
|
||
const id = item?.dataset.routeId;
|
||
const source = item?.dataset.routeSource;
|
||
return state.routes.find(route => routeId(route) === id && routeSource(route) === source);
|
||
}
|
||
|
||
function poiForItem(target) {
|
||
const item = target.closest('[data-poi-id]');
|
||
return state.pois.find(poi => poiId(poi) === item?.dataset.poiId);
|
||
}
|
||
|
||
function poiIndexForItem(target) {
|
||
const item = target.closest('[data-poi-id]');
|
||
return state.pois.findIndex(poi => poiId(poi) === item?.dataset.poiId);
|
||
}
|
||
|
||
async function deleteRouteFromList(route) {
|
||
const id = routeId(route);
|
||
if (!id || routeSource(route) !== 'local') return;
|
||
|
||
await ns.LocalRoutes.remove(id);
|
||
|
||
if (routeId(state.currentRoute) === id && state.currentSource === routeSource(route)) {
|
||
state.currentRoute = null;
|
||
state.currentSource = null;
|
||
state.currentPoi = null;
|
||
state.pois = [];
|
||
}
|
||
|
||
await loadRoutes();
|
||
showListView();
|
||
setStatus(ns.I18n.t("editor.route-deleted"));
|
||
}
|
||
|
||
async function routeListAction(event) {
|
||
const control = event.target.closest('[data-editor-action]');
|
||
const action = control?.dataset.editorAction;
|
||
const route = routeForItem(event.target);
|
||
if (!route) return;
|
||
|
||
if (action === 'share-route') {
|
||
if (shareInProgress) return;
|
||
shareInProgress = true;
|
||
try {
|
||
const code = await ns.Groups.chooseCode();
|
||
if (!code) return;
|
||
let sourceId = routeId(route);
|
||
if (routeSource(route) === 'local') {
|
||
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
|
||
await loadRoute(route);
|
||
await uploadLocalRoute();
|
||
sourceId = state.currentRoute.serverRouteId;
|
||
}
|
||
await ns.Groups.share(sourceId, code);
|
||
} finally { shareInProgress = false; }
|
||
return;
|
||
}
|
||
if (action === 'edit-route') {
|
||
await loadRoute(route);
|
||
return;
|
||
}
|
||
if (action === 'delete-route' && routeSource(route) === 'local') {
|
||
await deleteRouteFromList(route);
|
||
}
|
||
}
|
||
|
||
async function deletePoiFromList(poi) {
|
||
if (!poi) return;
|
||
if (state.currentSource === 'group') {
|
||
if (!await editPoi(poi)) return;
|
||
state.groupDeletePoint = true;
|
||
await savePoi();
|
||
return;
|
||
}
|
||
state.currentPoi = poi;
|
||
await deleteCurrentPoi();
|
||
}
|
||
|
||
async function poiListAction(event) {
|
||
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
|
||
if (action === 'edit-poi') {
|
||
await editPoi(poiForItem(event.target));
|
||
return;
|
||
}
|
||
if (action === 'delete-poi') {
|
||
await deletePoiFromList(poiForItem(event.target));
|
||
return;
|
||
}
|
||
if (action === 'move-poi-up') {
|
||
swapPois(poiIndexForItem(event.target), -1);
|
||
return;
|
||
}
|
||
if (action === 'move-poi-down') swapPois(poiIndexForItem(event.target), 1);
|
||
}
|
||
|
||
async function guarded(action) {
|
||
try {
|
||
await action();
|
||
await ns.StorageUsage?.refresh();
|
||
} catch (error) {
|
||
ns.StorageUsage?.reportError(error);
|
||
setStatus(ns.UserMessages?.error(error) || ns.I18n.t("editor.error.generic"), true);
|
||
}
|
||
}
|
||
|
||
function bind() {
|
||
for (const eventName of ['input', 'change', 'pointerdown', 'keydown']) {
|
||
byId(ids.poiForm)?.addEventListener(eventName, () => {
|
||
if (state.currentSource === 'group') ns.GroupPoints?.noteActivity?.();
|
||
});
|
||
}
|
||
document.addEventListener('wegwichtel:point-lock-lost', () => {
|
||
if (state.currentSource === 'group') setStatus(ns.I18n.t('point-locks.lost'));
|
||
});
|
||
for (const eventName of ['cancel', 'close']) {
|
||
byId(ids.dialog)?.addEventListener(eventName, event => {
|
||
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
|
||
releasePoiPreview();
|
||
ns.GroupPoints?.release();
|
||
ns.RouteRecorder?.endEditing();
|
||
});
|
||
}
|
||
activate(byId(ids.button), () => guarded(openEditor));
|
||
activate(byId(ids.close), closeEditor);
|
||
activate(byId(ids.newRoute), newRoute);
|
||
activate(byId(ids.routeBack), () => { showListView(); guarded(loadRoutes); });
|
||
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
|
||
activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms'));
|
||
activate(byId(ids.addPoi), newPoi);
|
||
activate(byId(ids.poiAudioAdd), () => guarded(() => chooseMedia('audio', ids.poiAudio)));
|
||
activate(byId(ids.poiImagesAdd), () => guarded(() => chooseMedia('image', ids.poiImages)));
|
||
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
|
||
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
|
||
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
|
||
activate(byId(ids.poiDelete), () => {
|
||
if (state.currentSource === 'group') { state.groupDeletePoint = true; guarded(savePoi); }
|
||
else guarded(deleteCurrentPoi);
|
||
});
|
||
activate(byId(ids.poiCancel), showRouteView);
|
||
activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
|
||
activate(byId(ids.poiList), event => guarded(() => poiListAction(event)));
|
||
submit(byId(ids.routeForm), () => guarded(saveRoute));
|
||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||
}
|
||
|
||
function editorApi() {
|
||
return Object.freeze({
|
||
close: closeEditor,
|
||
open: openEditor,
|
||
openNewLocalRoute,
|
||
openGroup: async route => {
|
||
await openEditor();
|
||
await loadRoute({ ...route, _source: 'group' });
|
||
},
|
||
openPendingPoint: async draft => {
|
||
await openEditor();
|
||
try { await loadRoute({ ...draft.route, _source: 'group' }); }
|
||
catch {
|
||
state.currentSource = 'group';
|
||
state.currentRoute = { ...draft.route, _source: 'group' };
|
||
state.pois = draft.basePoi ? [draft.basePoi] : [];
|
||
fillRouteForm(state.currentRoute); renderPoiList(); showRouteView();
|
||
}
|
||
if (draft.poiId === 'new') {
|
||
newPoi();
|
||
fillPoiForm(draft.values);
|
||
state.pendingAudioFile = draft.media?.audio || null;
|
||
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
|
||
renderPoiMediaPreview(); updatePoiMediaStatus();
|
||
} else await editPoi(draft.basePoi || { id: draft.poiId });
|
||
},
|
||
refresh: loadRoutes
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
bind();
|
||
ns.EditorUi = editorApi();
|
||
}
|
||
|
||
ready(init);
|
||
}(globalThis.Wegwichtel));
|