Files
Wegwichtel/public/js/editor-ui.js
T
Florian Zumpe b032940681
Sonarqube Scanner / Build and analyze (push) Successful in 1m26s
fix sonarqube findings
2026-09-27 18:19:55 +02:00

1244 lines
42 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',
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',
iconButton: 'editor-icon-button',
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,
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 routeName(route) {
return text(route?.name || route?.title || 'Unbenannte Strecke');
}
function poiTitle(poi) {
return text(poi?.title || poi?.name || 'Unbenannte 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 = 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 ? 'POI bearbeiten' : 'POI 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) {
const button = document.createElement('button');
button.className = 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'));
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', `Position ${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'));
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 Stationen 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 = '';
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 resetPendingMedia() {
state.pendingAudioFile = null;
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?.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 audio = document.createElement('span');
audio.className = classes.mediaPreviewAudio;
audio.setAttribute('aria-hidden', 'true');
audio.textContent = '♪';
select.appendChild(audio);
}
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 (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.preview?.dataUrl || null,
source: 'pending'
}));
});
}
function updatePoiMediaStatus(poi = state.currentPoi) {
const status = byId(ids.poiMediaStatus);
if (!status) return;
const existingAudio = 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 fillPoiForm(poi) {
resetPendingMedia();
byId(ids.poiId).value = poiId(poi);
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannte 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.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,
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
preview: await ns.LocalRoutes.fileToDataUrl(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 = ns.LocalRoutes.get(id);
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 gpx = new Blob([routePackage.gpx], { type: 'application/gpx+xml' });
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
return data;
}
async function storedMediaBlob(media) {
if (!media?.dataUrl) return null;
const response = await fetch(media.dataUrl);
if (!response.ok || typeof response.blob !== 'function') {
throw new Error(`Die Mediendatei ${media.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
}
return response.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?.dataUrl) {
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
}
for (const [index, image] of (poi.images || []).entries()) {
if (!image?.dataUrl) 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 || `Station ${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-POI-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 POI muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
return;
}
const accuracy = Number(position?.accuracy);
state.currentPoi = null;
fillPoiForm({
lat,
lon,
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
});
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
showPoiView();
}
function editPoi(poi) {
state.currentPoi = poi;
fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = 'Station bearbeiten';
showPoiView();
}
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
const audio = state.pendingAudioFile
? await ns.LocalRoutes.fileToDataUrl(state.pendingAudioFile)
: existingPoi?.audio || null;
const images = [...(existingPoi?.images || [])];
for (const pending of state.pendingImages) {
const image = pending.preview || await ns.LocalRoutes.fileToDataUrl(pending.file);
images.push({
...image,
id: `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`,
caption: ''
});
}
return { audio, images };
}
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
});
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;
return saved;
}
async function saveLocalPoi(values) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new TypeError('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
}
const currentId = Number(byId(ids.poiId).value);
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
const media = await mediaFromLocalForm(existing, identifier);
const savedPoi = {
...existing,
...values,
...media,
id: identifier,
sequence: existing?.sequence ?? state.pois.length
};
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
if (index >= 0) state.pois[index] = savedPoi;
else state.pois.push(savedPoi);
await persistLocalCurrentRoute();
setStatus('GPX-POI und zugehörige JSON-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('Station gespeichert.');
await loadRoute({ id: currentRouteId, _source: 'server' });
}
async function savePoi() {
const values = poiFromForm();
if (!values.title) throw new Error('Der Titel der Station 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;
} else {
const index = Number.parseInt(mediaIndex, 10);
if (Number.isInteger(index)) state.pendingImages.splice(index, 1);
}
state.selectedMediaKey = null;
renderPoiMediaPreview();
updatePoiMediaStatus();
}
async function removeExistingLocalMedia(kind, mediaIndex) {
if (!state.currentPoi) return;
const updated = { ...state.currentPoi };
if (kind === 'audio') {
updated.audio = null;
} else {
const index = Number.parseInt(mediaIndex, 10);
updated.images = [...(state.currentPoi.images || [])];
if (Number.isInteger(index)) updated.images.splice(index, 1);
}
updateCurrentPoi(updated);
await persistLocalCurrentRoute();
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 POI 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') {
state.pois = state.pois
.filter(poi => poiId(poi) !== currentPoiId)
.map((poi, sequence) => ({ ...poi, sequence }));
state.currentPoi = null;
await persistLocalCurrentRoute();
renderPoiList();
showRouteView();
setStatus('POI entfernt und lokale Strecke aktualisiert.');
return;
}
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' });
setStatus('POI 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 POI-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 routeListAction(event) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
if (action !== 'edit-route') return;
await guarded(() => loadRoute(routeForItem(event.target)));
}
function poiListAction(event) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
if (action === 'edit-poi') {
editPoi(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), () => openFilePicker(ids.poiAudio));
activate(byId(ids.poiImagesAdd), () => openFilePicker(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), poiListAction);
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));