Files
Wegwichtel/public/js/editor-ui.js
T
Florian Zumpe 0e6f40ebc3
Sonarqube Scanner / Build and analyze (push) Failing after 17s
refined class usage for usage and coverage tests
2026-09-26 20:56:36 +02:00

770 lines
25 KiB
JavaScript

globalThis.Wegwichtel = globalThis.Wegwichtel || {};
(function (ns) {
'use strict';
const ids = Object.freeze({
addPoi: 'editor-add-poi',
button: 'editor-button',
close: 'editor-close',
dialog: 'editor-dialog',
dialogTitle: 'editor-dialog-title',
newRoute: 'editor-new-route',
poiAudio: 'editor-poi-audio',
poiCancel: 'editor-poi-cancel',
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',
poiLat: 'editor-poi-lat',
poiList: 'editor-poi-list',
poiLon: 'editor-poi-lon',
poiRadius: 'editor-poi-radius',
poiTitle: 'editor-poi-title',
routeActivated: 'editor-route-activated',
routeBack: 'editor-route-back',
routeDescription: 'editor-route-description',
routeDetailView: 'editor-route-detail-view',
routeForm: 'editor-route-form',
routeGpx: 'editor-route-gpx',
routeId: 'editor-route-id',
routeList: 'editor-route-list',
routeListView: 'editor-route-list-view',
routeName: 'editor-route-name',
status: 'editor-status'
});
const classes = Object.freeze({
iconButton: 'editor-icon-button',
iconRow: 'editor-icon-row',
muted: 'editor-muted',
poiItem: 'editor-poi-item',
routeGroup: 'editor-route-group',
routeItem: 'editor-route-item'
});
const state = {
currentPoi: null,
currentRoute: null,
currentSource: null,
pois: [],
routes: []
};
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();
throw new Error(message || `HTTP ${response.status}`);
}
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);
}
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 = '';
if (state.currentSource === 'local') {
byId(ids.routeActivated).textContent = 'Lokaler Entwurf: GPX, Texte und Medien bleiben ausschließlich im Browser. Eine Server-Freigabe ist noch nicht implementiert.';
} else {
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 fillPoiForm(poi) {
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);
byId(ids.poiAudio).value = '';
byId(ids.poiImages).value = '';
}
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('file', 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;
setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.');
await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' });
}
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' };
setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.');
await loadRoutes();
await loadRoute({ id, _source: 'server' });
}
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 currentPosition() {
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
}
function newPoi() {
state.currentPoi = null;
const position = currentPosition();
fillPoiForm({
lat: position?.lat ?? '',
lon: position?.lon ?? '',
triggerRadiusM: 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 audioFile = byId(ids.poiAudio)?.files?.[0];
const imageFiles = Array.from(byId(ids.poiImages)?.files || []);
const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null;
const images = [...(existingPoi?.images || [])];
for (const imageFile of imageFiles) {
const image = await ns.LocalRoutes.fileToDataUrl(imageFile);
images.push({
...image,
id: `${poiIdentifier}-image-${Date.now()}-${images.length}`,
caption: ''
});
}
return { audio, images };
}
async function persistLocalCurrentRoute() {
const id = routeId(state.currentRoute);
if (!id) throw new Error('Speichere zuerst die Strecke.');
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;
return saved;
}
async function saveLocalPoi(values) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new Error('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, file) {
const data = new FormData();
data.append('file', file);
return sendForm(path, 'POST', data);
}
async function uploadPoiMedia(currentRouteId, currentPoiId) {
const audio = byId(ids.poiAudio)?.files?.[0];
const images = Array.from(byId(ids.poiImages)?.files || []);
if (audio) await uploadOne(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}/audio`, audio);
for (const image of images) {
await uploadOne(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}/images`, image);
}
}
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 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.addPoi), newPoi);
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));