Files
Wegwichtel/public/js/editor-ui.js
T
Florian Zumpe 0db91660b3
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Successful in 21s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m3s
Sonarqube Scanner / Deploy to Server (push) Successful in 6s
allow update for uploaded routes and implement creator id for routes
2026-10-03 16:43:08 +02:00

1465 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
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,
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");
}
function showListView() {
ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung');
setHidden(byId(ids.routeListView), false);
setHidden(byId(ids.routeDetailView), true);
setHidden(byId(ids.poiFormView), true);
}
function showRouteView() {
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 (response.ok) 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: { 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: {
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: { Accept: 'application/json' },
method
});
return handleResponse(response);
}
async function sendDelete(path) {
const response = await fetch(apiUrl(path), {
credentials: 'same-origin',
headers: { 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"));
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-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.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route"));
renderRouteGroup(list, ns.I18n.t("editor.drafts"), localRoutes, ns.I18n.t("editor.no-draft"));
}
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'
}));
}
async function loadRoutes() {
setStatus(ns.I18n.t("editor.loading-routes"));
const payload = await readJson('/routes?activatedOnly=true');
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
let localRoutes = [];
try {
localRoutes = localRouteSummaries();
} catch (error) {
setStatus(error.message, true);
}
state.routes = [...serverRoutes, ...localRoutes];
renderRouteList();
if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
}
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);
}
async function openNewLocalRoute() {
await openEditor();
newRoute();
}
function closeEditor() {
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');
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') {
const uploaded = Boolean(route?.serverRouteId);
setHidden(uploadButton, false);
setDisabled(uploadButton, false);
if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit");
byId(ids.routeActivated).textContent = uploaded
? ns.I18n.t("editor.submitted-hint")
: ns.I18n.t("editor.draft-hint");
} 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 (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
ns.RouteRecorder.endEditing();
}
setStatus(ns.I18n.t("editor.route.loading"));
state.currentSource = routeSource(route);
if (state.currentSource === 'local') {
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
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: 'server' };
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);
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];
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: 'server' };
await loadRoutes();
await loadRoute({ id, _source: 'server' });
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 === 'server') await saveServerRoute(values);
else await saveLocalRoute(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);
}
async function createServerRouteFromLocal(routePackage) {
const checkpoint = () => {
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);
};
let id = routePackage.route.serverRouteId;
let previousPois = [];
if (id) {
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
const existing = payload.route || payload;
if (existing.activatedAt || existing.activated_at) {
throw new Error(ns.I18n.t("editor.read-only-hint"));
}
previousPois = existing.pois || [];
await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage));
} else {
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
id = routeId(payload.route || payload);
if (!id) throw new Error(ns.I18n.t("editor.error.send-route"));
routePackage.route.serverRouteId = id;
checkpoint();
}
const orderedPois = [...(routePackage.pois || [])]
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
for (const [index, poi] of orderedPois.entries()) {
const base = `/routes/${encodeURIComponent(id)}/pois`;
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 || '' }));
checkpoint();
const mediaBase = `${base}/${poi.serverPoiId}`;
const oldPoi = previousPois.find(old => String(old.id) === String(poi.serverPoiId));
if (poi.audio) {
await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin',
oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST');
poi.serverAudio = true;
checkpoint();
} else if (oldPoi?.audioUrl) {
await sendDelete(`${mediaBase}/audio`);
poi.serverAudio = false;
checkpoint();
}
for (const [imageIndex, image] of (poi.images || []).entries()) {
const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`;
const picture = await uploadStoredMedia(path, 'picture', image, `bild-${imageIndex + 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 || '' }));
checkpoint();
}
for (const oldImage of oldPoi?.images || []) {
if (!(poi.images || []).some(image => String(image.serverPictureId) === String(oldImage.id))) {
await sendDelete(`${mediaBase}/pictures/${oldImage.id}`);
}
}
}
for (const oldPoi of previousPois) {
if (!orderedPois.some(poi => String(poi.serverPoiId) === String(oldPoi.id))) {
await sendDelete(`/routes/${encodeURIComponent(id)}/pois/${oldPoi.id}`);
}
}
return id;
}
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 serverRouteId = 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("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() {
const position = currentPosition();
const lat = position?.lat ?? null;
const lon = position?.lon ?? null;
const accuracy = Number(position?.accuracy);
state.currentPoi = null;
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();
}
function editPoi(poi) {
state.currentPoi = poi;
fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
showPoiView();
}
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 hydrated = await ns.LocalRoutes.getWithMedia(id) || saved;
state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' };
state.pois = hydrated.pois;
state.currentPoi = currentPoiId
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
: null;
return hydrated;
}
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);
}
for (const pending of state.pendingImages) {
await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
}
}
async function saveServerPoi(values) {
const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first"));
const currentPoiId = byId(ids.poiId).value;
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 (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
setStatus(ns.I18n.t("editor.station-saved"));
await loadRoute({ id: currentRouteId, _source: 'server' });
}
async function savePoi() {
const values = poiFromForm();
if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name"));
if (state.currentSource === 'local') await saveLocalPoi(values);
else await saveServerPoi(values);
}
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) 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') {
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"));
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();
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;
}
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' });
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 savePoiOrder() {
const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) return;
if (state.currentSource === 'local') {
await persistLocalCurrentRoute();
setStatus(ns.I18n.t("editor.station-order-saved"));
return;
}
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 === 'edit-route') {
await loadRoute(route);
return;
}
if (action === 'delete-route' && routeSource(route) === 'local') {
await deleteRouteFromList(route);
}
}
async function deletePoiFromList(poi) {
if (!poi) return;
state.currentPoi = poi;
await deleteCurrentPoi();
}
async function poiListAction(event) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
if (action === 'edit-poi') {
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 ['cancel', 'close']) {
byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
}
activate(byId(ids.button), () => guarded(openEditor));
activate(byId(ids.close), closeEditor);
activate(byId(ids.newRoute), newRoute);
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
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), () => 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,
refresh: loadRoutes
});
}
function init() {
bind();
ns.EditorUi = editorApi();
}
ready(init);
}(globalThis.Wegwichtel));