644 lines
19 KiB
JavaScript
644 lines
19 KiB
JavaScript
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|
|
|
(function (ns) {
|
|
'use strict';
|
|
|
|
const ids = Object.freeze({
|
|
addPoi: 'editor-add-poi',
|
|
button: 'editor-button',
|
|
coordinateGrid: 'editor-coordinate-grid',
|
|
close: 'editor-close',
|
|
dialog: 'editor-dialog',
|
|
entryTitle: 'editor-entry-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',
|
|
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',
|
|
status: 'editor-status',
|
|
title: 'editor-dialog-title'
|
|
});
|
|
|
|
|
|
const classes = Object.freeze({
|
|
button: 'editor-button',
|
|
dialog: 'editor-dialog',
|
|
dialogHeader: 'editor-dialog-header',
|
|
dialogShell: 'editor-dialog-shell',
|
|
entryPanel: 'editor-entry-panel',
|
|
form: 'editor-form',
|
|
iconButton: 'editor-icon-button',
|
|
iconRow: 'editor-icon-row',
|
|
muted: 'editor-muted',
|
|
poiItem: 'editor-poi-item',
|
|
poiList: 'editor-poi-list',
|
|
poiToolbar: 'editor-poi-toolbar',
|
|
primaryButton: 'editor-primary-button',
|
|
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,
|
|
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 showListView() {
|
|
setHidden(byId(ids.routeListView), false);
|
|
setHidden(byId(ids.routeDetailView), true);
|
|
setHidden(byId(ids.poiFormView), true);
|
|
}
|
|
|
|
function showRouteView() {
|
|
setHidden(byId(ids.routeListView), true);
|
|
setHidden(byId(ids.routeDetailView), false);
|
|
setHidden(byId(ids.poiFormView), true);
|
|
}
|
|
|
|
function showPoiView() {
|
|
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 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 handleResponse(response) {
|
|
if (response.ok) return parseJson(response);
|
|
const message = await response.text();
|
|
throw new Error(message || `HTTP ${response.status}`);
|
|
}
|
|
|
|
async function parseJson(response) {
|
|
const raw = await response.text();
|
|
if (!raw.trim()) return {};
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
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 routeItem(route) {
|
|
const item = document.createElement('li');
|
|
item.className = classes.routeItem;
|
|
item.dataset.routeId = routeId(route);
|
|
|
|
const label = document.createElement('div');
|
|
appendText(label, 'strong', routeName(route));
|
|
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = classes.iconRow;
|
|
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
|
|
|
item.append(label, actions);
|
|
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 renderRouteList() {
|
|
const list = byId(ids.routeList);
|
|
if (!list) return;
|
|
list.textContent = '';
|
|
if (!state.routes.length) {
|
|
const item = document.createElement('li');
|
|
item.className = classes.routeItem;
|
|
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
|
list.appendChild(item);
|
|
return;
|
|
}
|
|
state.routes.forEach(route => list.appendChild(routeItem(route)));
|
|
}
|
|
|
|
function renderPoiList() {
|
|
const list = byId(ids.poiList);
|
|
if (!list) return;
|
|
list.textContent = '';
|
|
if (!state.pois.length) {
|
|
const item = document.createElement('li');
|
|
item.className = classes.poiItem;
|
|
appendText(item, 'span', 'Noch keine Stationen angelegt.');
|
|
list.appendChild(item);
|
|
return;
|
|
}
|
|
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
|
}
|
|
|
|
async function loadRoutes() {
|
|
setStatus('Strecken werden geladen …');
|
|
const payload = await readJson('/routes');
|
|
state.routes = routeArray(payload);
|
|
renderRouteList();
|
|
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);
|
|
}
|
|
|
|
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),
|
|
latitude: numberOrNull(byId(ids.poiLat)?.value),
|
|
lon: numberOrNull(byId(ids.poiLon)?.value),
|
|
longitude: numberOrNull(byId(ids.poiLon)?.value),
|
|
radiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
|
sortOrder: state.currentPoi?.sortOrder ?? 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 = '';
|
|
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
|
? `Aktiviert am ${route.activatedAt || route.activated_at}. Änderung der Freigabe erfolgt weiterhin manuell in der Datenbank.`
|
|
: 'Nicht veröffentlicht: activated_at ist leer und wird durch den Editor nicht gesetzt.';
|
|
}
|
|
|
|
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?.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 …');
|
|
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
|
const detail = payload.route || payload;
|
|
state.currentRoute = detail;
|
|
state.pois = poiArray(detail);
|
|
fillRouteForm(detail);
|
|
renderPoiList();
|
|
showRouteView();
|
|
setStatus('');
|
|
}
|
|
|
|
function newRoute() {
|
|
state.currentRoute = null;
|
|
state.currentPoi = null;
|
|
state.pois = [];
|
|
fillRouteForm({});
|
|
renderPoiList();
|
|
showRouteView();
|
|
}
|
|
|
|
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 saveRoute() {
|
|
const values = routeFromForm();
|
|
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
|
|
const id = byId(ids.routeId).value;
|
|
const file = byId(ids.routeGpx)?.files?.[0];
|
|
const path = id ? `/routes/${encodeURIComponent(id)}` : '/routes';
|
|
const method = id ? 'PUT' : 'POST';
|
|
const payload = file ? await sendForm(path, method, formDataFromRoute(values)) : await sendJson(path, method, values);
|
|
const saved = payload.route || payload;
|
|
state.currentRoute = Object.keys(saved).length ? saved : { ...values, id };
|
|
setStatus('Strecke gespeichert. Neue Strecken bleiben bis zur manuellen Aktivierung unveröffentlicht.');
|
|
await loadRoutes();
|
|
if (routeId(state.currentRoute)) await loadRoute(state.currentRoute);
|
|
}
|
|
|
|
function newPoi() {
|
|
state.currentPoi = null;
|
|
fillPoiForm({ radiusM: 25 });
|
|
showPoiView();
|
|
}
|
|
|
|
function editPoi(poi) {
|
|
state.currentPoi = poi;
|
|
fillPoiForm(poi);
|
|
showPoiView();
|
|
}
|
|
|
|
async function savePoi() {
|
|
const route = state.currentRoute;
|
|
const currentRouteId = routeId(route);
|
|
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
|
const values = poiFromForm();
|
|
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
|
|
|
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(route);
|
|
}
|
|
|
|
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 uploadOne(path, file) {
|
|
const data = new FormData();
|
|
data.append('file', file);
|
|
return sendForm(path, 'POST', data);
|
|
}
|
|
|
|
function swapPois(index, offset) {
|
|
const target = index + offset;
|
|
if (target < 0 || target >= state.pois.length) return;
|
|
const reordered = [...state.pois];
|
|
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
|
state.pois = reordered;
|
|
renderPoiList();
|
|
guarded(savePoiOrder);
|
|
}
|
|
|
|
async function savePoiOrder() {
|
|
const currentRouteId = routeId(state.currentRoute);
|
|
if (!currentRouteId) 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, position: index, sortOrder: index });
|
|
}).filter(Boolean);
|
|
await Promise.all(jobs);
|
|
setStatus('Reihenfolge gespeichert.');
|
|
}
|
|
|
|
function routeForItem(target) {
|
|
const item = target.closest('[data-route-id]');
|
|
return state.routes.find(route => routeId(route) === item?.dataset.routeId);
|
|
}
|
|
|
|
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 classNames() {
|
|
return Object.values(classes);
|
|
}
|
|
|
|
function testHooks() {
|
|
return Object.freeze({
|
|
apiUrl,
|
|
classNames,
|
|
closeEditor,
|
|
fillPoiForm,
|
|
fillRouteForm,
|
|
handleResponse,
|
|
isActivation,
|
|
loadRoute,
|
|
loadRoutes,
|
|
newPoi,
|
|
newRoute,
|
|
poiArray,
|
|
poiFromForm,
|
|
poiItem,
|
|
parseJson,
|
|
readJson,
|
|
renderPoiList,
|
|
renderRouteList,
|
|
routeArray,
|
|
routeFromForm,
|
|
routeItem,
|
|
savePoi,
|
|
sendForm,
|
|
sendJson,
|
|
setDialogExpanded,
|
|
setDisabled,
|
|
setStatus,
|
|
savePoiOrder,
|
|
saveRoute,
|
|
state,
|
|
swapPois,
|
|
uploadPoiMedia
|
|
});
|
|
}
|
|
|
|
function editorApi() {
|
|
const api = {
|
|
close: closeEditor,
|
|
classNames,
|
|
open: openEditor,
|
|
refresh: loadRoutes
|
|
};
|
|
|
|
if (globalThis.WegwichtelEditorTestHooks === true) api.testHooks = testHooks();
|
|
return Object.freeze(api);
|
|
}
|
|
|
|
function init() {
|
|
bind();
|
|
ns.EditorUi = editorApi();
|
|
}
|
|
|
|
ready(init);
|
|
}(globalThis.Wegwichtel));
|