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 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) { 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); 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 label = document.createElement('div'); appendText(label, 'strong', poiTitle(poi)); appendText(label, 'small', `Position ${index + 1}`); const actions = document.createElement('div'); actions.className = classes.iconRow; actions.appendChild(iconButton('Station nach oben schieben', '↑', 'move-poi-up')); actions.appendChild(iconButton('Station nach unten schieben', '↓', 'move-poi-down')); actions.appendChild(iconButton('Station 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', ''); } 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'); setStatus(''); } function routeFromForm() { return { activatedAt: null, activated_at: null, 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); data.append('activated_at', ''); 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)); if (action === 'move-poi-up') swapPois(poiIndexForItem(event.target), -1); 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, 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));