Files
Wegwichtel/public/js/editor-ui.js
T
Florian Zumpe 175fe6394c
Sonarqube Scanner / Build and analyze (push) Failing after 56s
sonarqube fix
2026-09-27 22:35:57 +02:00

1428 lines
48 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('Für diese lokale Strecke kann keine weitere Streckenpunkt-ID vergeben werden.');
}
return next;
}
function routeName(route) {
return text(route?.name || route?.title || 'Unbenannte Strecke');
}
function poiTitle(poi) {
return text(poi?.title || poi?.name || 'Unbenannter Streckenpunkt');
}
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 = 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) ? 'Lokale Strecke bearbeiten' : 'Neue lokale Strecke';
}
return 'Veröffentlichte Strecke bearbeiten';
}
function showListView() {
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 ? 'Streckenpunkt bearbeiten' : 'Streckenpunkt hinzufügen');
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 message = await response.text();
const error = new Error(message || `HTTP ${response.status}`);
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' ? 'Lokaler Entwurf' : 'Veröffentlichte Strecke');
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
if (routeSource(route) === 'local') {
actions.appendChild(iconButton('Strecke löschen', '×', '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', `Streckenpunkt ${index + 1}`);
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
actions.appendChild(iconButton(`${title} löschen`, '×', '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, 'Veröffentlichte Strecken', serverRoutes, 'Keine veröffentlichte Strecke vorhanden.');
renderRouteGroup(list, 'Lokale Strecken', localRoutes, 'Noch kein lokaler Entwurf vorhanden.');
}
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', 'Noch keine Streckenpunkte angelegt.');
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('Strecken werden geladen …');
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() {
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) {
byId(ids.routeId).value = routeId(route);
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : 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, uploaded);
if (uploadButton) uploadButton.textContent = uploaded ? 'Bereits übertragen' : 'An Server übertragen';
byId(ids.routeActivated).textContent = uploaded
? `Lokaler Entwurf. Bereits als unveröffentlichte Server-Strecke ${route.serverRouteId} zur Prüfung übertragen.`
: 'Lokaler Entwurf: GPX, Texte und Medien bleiben im Browser, bis „An Server übertragen“ gewählt wird.';
} else {
setHidden(uploadButton, true);
setDisabled(uploadButton, true);
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.`
: 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.';
}
}
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', `${label}: Entfernen-Schaltfläche ${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 || 'Audioaufnahme',
source: 'existing'
}));
}
if (state.pendingAudioFile) {
preview.appendChild(mediaPreviewCard({
key: 'pending-audio',
kind: 'audio',
label: state.pendingAudioFile.name || 'Neue Audioaufnahme',
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 || `Bild ${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 || `Neues Bild ${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(`${audioCount} Audio${audioCount === 1 ? 'aufnahme' : 'aufnahmen'}`);
if (imageCount) parts.push(`${imageCount} Bild${imageCount === 1 ? '' : 'er'}`);
status.textContent = parts.length
? `${parts.join(' · ')}. Vorschau antippen, um die Entfernen-Schaltfläche einzublenden.`
: 'Noch keine Medien vorhanden.';
}
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: 'Audio', accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }]
};
}
return {
multiple: true,
types: [{ description: 'Bilder', 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) === 'Unbenannter Streckenpunkt' ? '' : 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;
setStatus('Streckendetails werden geladen …');
state.currentSource = routeSource(route);
if (state.currentSource === 'local') {
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
state.currentRoute = {
...routePackage.route,
id: routePackage.id,
gpx: routePackage.gpx,
_source: 'local'
};
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() {
state.currentSource = 'local';
state.currentRoute = {
id: '',
name: '',
description: '',
gpx: ns.LocalRoutes.emptyGpx('Neue Strecke'),
_source: 'local'
};
state.currentPoi = null;
state.pois = [];
fillRouteForm(state.currentRoute);
renderPoiList();
showRouteView();
setStatus('Neue Strecke wird als lokaler GPX-/JSON-Entwurf angelegt.');
}
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 = 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;
}
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';
state.pois = saved.pois;
await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' });
setStatus('Speichern erfolgreich.');
}
async function saveServerRoute(values) {
const id = routeId(state.currentRoute);
if (!id) throw new Error('Neue Strecken werden ausschließlich lokal angelegt.');
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('Speichern erfolgreich.');
}
async function saveRoute() {
const values = routeFromForm();
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
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 || 'Unbenannte Strecke');
data.append('description', routePackage.route?.description || '');
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(`Die Mediendatei ${media?.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
}
return blob;
}
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
const blob = await storedMediaBlob(media);
if (!blob) return;
const data = new FormData();
data.append(fieldName, blob, media.name || fallbackName);
await sendForm(path, 'POST', data);
}
async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
if (poi.audio) {
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
}
for (const [index, image] of (poi.images || []).entries()) {
if (!image) continue;
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
}
}
async function createServerRouteFromLocal(routePackage) {
let createdRouteId = null;
try {
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
const serverRoute = payload.route || payload;
createdRouteId = routeId(serverRoute);
if (!createdRouteId) throw new Error('Der Server hat keine Strecken-ID für den Upload zurückgegeben.');
const orderedPois = [...(routePackage.pois || [])]
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
for (const [index, poi] of orderedPois.entries()) {
const poiTitle = poi.title || `Streckenpunkt ${index + 1}`;
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
description: poi.description || '',
lat: poi.lat,
lon: poi.lon,
sequence: index,
title: poiTitle,
triggerRadiusM: poi.triggerRadiusM || 25
});
const serverPoi = poiPayload.poi || poiPayload;
const serverPoiId = poiId(serverPoi);
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-ID zurückgegeben.`);
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
}
return createdRouteId;
} catch (error) {
if (createdRouteId) {
try {
await sendDelete(`/routes/${encodeURIComponent(createdRouteId)}`);
} catch {
// Der ursprüngliche Uploadfehler ist wichtiger als ein fehlgeschlagener Rollback.
}
}
throw error;
}
}
async function uploadLocalRoute() {
if (state.currentSource !== 'local') throw new Error('Nur lokale Strecken können hochgeladen werden.');
const values = routeFromForm();
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
if (state.currentRoute?.serverRouteId) {
setStatus('Diese Strecke wurde bereits an den Server übertragen.');
return;
}
const uploadButton = byId(ids.routeUpload);
setDisabled(uploadButton, true);
try {
await saveLocalRoute(values);
setStatus('Übertragung läuft …');
const localId = routeId(state.currentRoute);
const routePackage = ns.LocalRoutes.get(localId);
if (!routePackage) throw new Error('Der lokale Streckenentwurf wurde nicht gefunden.');
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('Upload erfolgreich.');
} finally {
if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false);
}
}
function currentPosition() {
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
}
function newPoi() {
const position = currentPosition();
const lat = Number(position?.lat);
const lon = Number(position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setStatus('Für einen neuen Streckenpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
return;
}
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 = 'Streckenpunkt hinzufügen';
showPoiView();
}
function editPoi(poi) {
state.currentPoi = poi;
fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = 'Streckenpunkt bearbeiten';
showPoiView();
}
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
const routeIdentifier = routeId(state.currentRoute);
if (!routeIdentifier) throw new Error('Speichere zuerst die Strecke.');
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('Speichere zuerst die Strecke.');
const currentPoiId = poiId(state.currentPoi);
const saved = ns.LocalRoutes.save({
id,
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) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new TypeError('Für einen Streckenpunkt sind gültige Breiten- und Längengrade erforderlich.');
}
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('Streckenpunkt und zugehörige Mediendaten lokal gespeichert.');
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('Speichere zuerst die Strecke.');
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('Streckenpunkt gespeichert.');
await loadRoute({ id: currentRouteId, _source: 'server' });
}
async function savePoi() {
const values = poiFromForm();
if (!values.title) throw new Error('Der Titel des Streckenpunkts ist erforderlich.');
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' ? 'Audioaufnahme entfernt.' : 'Bild entfernt.');
}
async function removeExistingServerMedia(kind, mediaId) {
const currentRouteId = routeId(state.currentRoute);
const currentPoiId = poiId(state.currentPoi);
if (!currentRouteId || !currentPoiId) throw new Error('Der Streckenpunkt ist noch nicht gespeichert.');
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('Das Bild besitzt keine Server-ID und kann nicht entfernt werden.');
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' ? 'Audioaufnahme entfernt.' : 'Bild entfernt.');
}
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('Streckenpunkt entfernt und lokale Strecke aktualisiert.');
return;
}
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' });
setStatus('Streckenpunkt entfernt.');
}
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('Lokale Streckenpunkt-Reihenfolge gespeichert.');
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('Reihenfolge gespeichert.');
}
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('Strecke gelöscht.');
}
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();
} catch (error) {
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true);
}
}
function bind() {
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));