globalThis.Wegwichtel = globalThis.Wegwichtel || {}; (function (ns) { 'use strict'; const ids = Object.freeze({ addPoi: 'editor-add-poi', button: 'editor-button', close: 'editor-close', dialog: 'editor-dialog', dialogTitle: 'editor-dialog-title', newRoute: 'editor-new-route', poiAudio: 'editor-poi-audio', poiAudioAdd: 'editor-poi-audio-add', poiCancel: 'editor-poi-cancel', poiDelete: 'editor-poi-delete', poiDescription: 'editor-poi-description', poiForm: 'editor-poi-form', poiFormTitle: 'editor-poi-form-title', poiFormView: 'editor-poi-form-view', poiId: 'editor-poi-id', poiImages: 'editor-poi-images', poiImagesAdd: 'editor-poi-images-add', poiLat: 'editor-poi-lat', poiList: 'editor-poi-list', poiLon: 'editor-poi-lon', poiMediaPreview: 'editor-poi-media-preview', poiMediaStatus: 'editor-poi-media-status', poiRadius: 'editor-poi-radius', poiSave: 'editor-poi-save', poiTitle: 'editor-poi-title', routeActivated: 'editor-route-activated', routeBack: 'editor-route-back', routeDescription: 'editor-route-description', routeDetailTitle: 'editor-route-detail-title', routeDetailView: 'editor-route-detail-view', routeForm: 'editor-route-form', routeGpx: 'editor-route-gpx', routeGpxGroup: 'editor-route-gpx-group', routeId: 'editor-route-id', routeList: 'editor-route-list', routeListTitle: 'editor-route-list-title', routeListView: 'editor-route-list-view', routeName: 'editor-route-name', routeSave: 'editor-route-save', routeUpload: 'editor-route-upload', rightsOpen: 'editor-rights-open', status: 'editor-status' }); const classes = Object.freeze({ button: 'editor-button', dialog: 'editor-dialog', dialogHeader: 'editor-dialog-header', dangerButton: 'editor-danger-button', dangerIcon: 'editor-danger-icon', dialogShell: 'editor-dialog-shell', formActions: 'editor-form-actions', form: 'editor-form', gpxGroup: 'editor-gpx-group', iconButton: 'editor-icon-button', iconDanger: 'editor-icon-danger', iconRow: 'editor-icon-row', mediaActions: 'editor-media-actions', mediaButton: 'editor-media-button', mediaIcon: 'editor-media-icon', mediaPreview: 'editor-media-preview', mediaPreviewAudio: 'editor-media-preview-audio', mediaPreviewCard: 'editor-media-preview-card', mediaPreviewImage: 'editor-media-preview-image', mediaPreviewSelect: 'editor-media-preview-select', mediaRemove: 'editor-media-remove', muted: 'editor-muted', poiItem: 'editor-poi-item', poiList: 'editor-poi-list', poiToolbar: 'editor-poi-toolbar', primaryButton: 'editor-primary-button', routeGroup: 'editor-route-group', routeItem: 'editor-route-item', routeList: 'editor-route-list', roundButton: 'editor-round-button', secondaryButton: 'editor-secondary-button', status: 'editor-status', toolbar: 'editor-toolbar', toolbarBack: 'editor-toolbar-back', view: 'editor-view' }); const state = { currentPoi: null, groupDeletions: [], groupDeletePoint: false, currentRoute: null, currentSource: null, pendingAudioFile: null, pendingAudioHandle: null, pendingImages: [], pois: [], routes: [], selectedMediaKey: null }; function ready(callback) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', callback, { once: true }); return; } callback(); } function byId(id) { return document.getElementById(id); } function text(value) { return String(value ?? ''); } function numberOrNull(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function routeId(route) { return text(route?.id ?? route?.routeId); } function poiId(poi) { return text(poi?.id ?? poi?.poiId); } function numericPoiId(value) { const raw = text(value).trim(); if (!raw) return null; const parsed = Number(raw); return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; } function nextLocalPoiId() { let highest = -1; for (const poi of state.pois) { const identifier = numericPoiId(poi?.id ?? poi?.poiId); if (identifier != null && identifier > highest) highest = identifier; } const next = highest + 1; if (!Number.isSafeInteger(next)) { throw new TypeError(ns.I18n.t("editor.error.add-station")); } return next; } function routeName(route) { return text(route?.name || route?.title || ns.I18n.t("editor.untitled-route")); } function poiTitle(poi) { return text(poi?.title || poi?.name || ns.I18n.t("editor.untitled-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 = isError ? (ns.UserMessages?.error(message) || message) : 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) ? ns.I18n.t("editor.edit-draft") : ns.I18n.t("editor.new-route"); } return ns.I18n.t("editor.view-route"); } let poiMediaGeneration = 0; let previewPoi = null; function releasePoiPreview() { poiMediaGeneration += 1; ns.LocalRoutes?.releasePoiMedia?.(previewPoi); previewPoi = null; } function showListView() { ns.GroupPoints?.release(); releasePoiPreview(); ns.RouteRecorder?.endEditing(); setDialogTitle('Streckenverwaltung'); setHidden(byId(ids.routeListView), false); setHidden(byId(ids.routeDetailView), true); setHidden(byId(ids.poiFormView), true); } function showRouteView() { ns.GroupPoints?.release(); releasePoiPreview(); setDialogTitle(routeDialogTitle()); setHidden(byId(ids.routeListView), true); setHidden(byId(ids.routeDetailView), false); setHidden(byId(ids.poiFormView), true); } function showPoiView() { setDialogTitle(state.currentPoi ? ns.I18n.t("editor.edit-station") : ns.I18n.t("editor.add-station")); 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 ([401, 403].includes(response.status) && state.currentSource === 'group') { throw new Error(ns.I18n.t('groups.error.access')); } if (response.status === 423) throw new Error(ns.I18n.t('point-locks.lost')); if (response.status === 409 && response.headers?.get('X-Group-Route')) { throw new Error(ns.I18n.t('groups.error.conflict')); } if (response.ok) { ns.Groups?.observe?.(response); ns.GroupPoints?.observe?.(response); return parseJson(response); } const payload = await parseJson(response); const error = new Error(payload.message || ns.I18n.t("editor.error.action")); error.status = response.status; throw error; } async function readJson(path) { const response = await fetch(apiUrl(path), { credentials: 'same-origin', headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), 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: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), 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: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' }, method }); return handleResponse(response); } async function sendDelete(path) { const response = await fetch(apiUrl(path), { credentials: 'same-origin', headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' }, method: 'DELETE' }); return handleResponse(response); } function routeArray(payload) { if (Array.isArray(payload)) return payload; if (Array.isArray(payload?.routes)) return payload.routes; if (Array.isArray(payload?.items)) return payload.items; return []; } function poiArray(route) { if (Array.isArray(route?.pois)) return route.pois; if (Array.isArray(route?.pointsOfInterest)) return route.pointsOfInterest; return []; } function appendText(parent, tagName, value) { const child = document.createElement(tagName); child.textContent = value; parent.appendChild(child); return child; } function iconButton(label, symbol, action, disabled = false, danger = false) { const button = document.createElement('button'); button.className = danger ? `${classes.iconButton} ${classes.iconDanger}` : classes.iconButton; button.dataset.editorAction = action; button.textContent = symbol; button.title = label; button.type = 'button'; button.setAttribute('aria-label', label); setDisabled(button, disabled); return button; } function routeSource(route) { return route?._source || route?.source || 'server'; } function routeItem(route) { const item = document.createElement('li'); item.className = classes.routeItem; item.dataset.routeId = routeId(route); item.dataset.routeSource = routeSource(route); const label = document.createElement('div'); appendText(label, 'strong', routeName(route)); appendText(label, 'small', routeSource(route) === 'local' ? ns.I18n.t("app.draft") : ns.I18n.t("editor.published-route")); if (routeSource(route) === 'local' && route.submissionStatus) { appendText(label, 'small', ns.I18n.t(`editor.${route.submissionStatus}`)); } const actions = document.createElement('div'); actions.className = classes.iconRow; actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-route')); if (ns.Groups?.shareButton) actions.appendChild(ns.Groups.shareButton('share-route')); if (routeSource(route) === 'local') { actions.appendChild(iconButton(ns.I18n.t("editor.delete-route"), '×', 'delete-route', false, true)); } item.append(label, actions); return item; } function routeGroup(title) { const item = document.createElement('li'); item.className = classes.routeGroup; item.textContent = title; return item; } function poiItem(poi, index) { const item = document.createElement('li'); item.className = classes.poiItem; item.dataset.poiId = poiId(poi); const title = poiTitle(poi); const label = document.createElement('div'); appendText(label, 'strong', title); appendText(label, 'small', ns.I18n.t("editor.station-number", { value1: index + 1 })); const actions = document.createElement('div'); actions.className = classes.iconRow; actions.appendChild(iconButton(ns.I18n.t("editor.station.up", { value1: title }), '↑', 'move-poi-up', index === 0)); actions.appendChild(iconButton(ns.I18n.t("editor.station.down", { value1: title }), '↓', 'move-poi-down', index === state.pois.length - 1)); actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi')); actions.appendChild(iconButton(ns.I18n.t("editor.delete-item", { value1: title }), '×', 'delete-poi', false, true)); item.append(label, actions); return item; } function renderRouteGroup(list, title, routes, emptyText) { list.appendChild(routeGroup(title)); if (!routes.length) { const item = document.createElement('li'); item.className = classes.routeItem; appendText(item, 'span', emptyText); list.appendChild(item); return; } routes.forEach(route => list.appendChild(routeItem(route))); } function renderRouteList() { const list = byId(ids.routeList); if (!list) return; list.textContent = ''; const serverRoutes = state.routes.filter(route => routeSource(route) === 'server'); const localRoutes = state.routes.filter(route => routeSource(route) === 'local'); renderRouteGroup(list, ns.I18n.t("editor.drafts"), localRoutes, ns.I18n.t("editor.no-draft")); renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-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', ns.I18n.t("editor.no-stations")); 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' })); } let routeListGeneration = 0; let shareInProgress = false; async function loadRoutes() { const requestGeneration = ++routeListGeneration; state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')]; renderRouteList(); setStatus(''); // Local drafts are usable even while this request is waiting for a connection. refreshPublicRoutes(requestGeneration); refreshSubmittedRoutes(requestGeneration); } async function refreshSubmittedRoutes(requestGeneration) { const submitted = localRouteSummaries().filter(route => route.serverRouteId); await Promise.allSettled(submitted.map(async route => { const existing = await findSubmittedRoute(route.serverRouteId); if (requestGeneration !== routeListGeneration) return; const latest = ns.LocalRoutes.get(route.id); if (!latest || String(latest.route.serverRouteId) !== String(route.serverRouteId)) return; const submissionStatus = submittedStatus(existing); if ((latest.route.submissionStatus || null) === submissionStatus) return; latest.route.submissionStatus = submissionStatus; const saved = ns.LocalRoutes.save(latest); if (state.currentSource === 'local' && routeId(state.currentRoute) === route.id) { state.currentRoute.submissionStatus = submissionStatus; renderLocalSubmissionStatus(saved.route); } })); if (requestGeneration !== routeListGeneration) return; state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')]; renderRouteList(); } function renderLocalSubmissionStatus(route) { const uploaded = Boolean(route?.serverRouteId); const uploadButton = byId(ids.routeUpload); if (uploadButton) uploadButton.textContent = ns.I18n.t(uploaded ? 'editor.submitted' : 'editor.submit'); const hint = uploaded ? 'editor.submitted-hint' : 'editor.draft-hint'; byId(ids.routeActivated).textContent = ns.I18n.t(route?.submissionStatus ? `editor.${route.submissionStatus}-hint` : hint); } async function refreshPublicRoutes(requestGeneration) { const listIsVisible = () => byId(ids.dialog)?.open && !byId(ids.routeListView)?.hidden; try { const payload = await readJson('/routes?activatedOnly=true'); if (requestGeneration !== routeListGeneration) return; const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' })); state.routes = [...localRouteSummaries(), ...serverRoutes]; renderRouteList(); } catch { if (requestGeneration === routeListGeneration && listIsVisible()) { setStatus(ns.I18n.t('editor.public-unavailable')); } } } 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); ns.Groups?.refresh?.(); } async function openNewLocalRoute() { await openEditor(); newRoute(); } function closeEditor() { if (pointSaveInProgress) return; ns.GroupPoints?.release(); releasePoiPreview(); ns.RouteRecorder?.endEditing(); 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) { setHidden(byId('editor-path-positions'), state.currentSource !== 'local'); setHidden(byId('editor-recording-options'), state.currentSource !== 'local'); if (state.currentSource === 'local') ns.RouteRecorder?.renderPoints(route.gpx || ns.LocalRoutes.emptyGpx(route.name)); byId(ids.routeId).value = routeId(route); byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor.untitled-route") ? '' : routeName(route); byId(ids.routeDescription).value = text(route?.description); byId(ids.routeGpx).value = ''; setHidden(byId(ids.routeGpxGroup), state.currentSource !== 'server'); const uploadButton = byId(ids.routeUpload); if (state.currentSource === 'local') { setHidden(uploadButton, false); setDisabled(uploadButton, false); renderLocalSubmissionStatus(route); } else { setHidden(uploadButton, true); setDisabled(uploadButton, true); byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at ? ns.I18n.t("editor.read-only-hint") : ns.I18n.t("editor.unpublished-hint"); } } function objectUrlFor(file) { if (!file || typeof globalThis.URL?.createObjectURL !== 'function') return null; return globalThis.URL.createObjectURL(file); } function releasePendingPreview(pending) { const url = pending?.previewUrl; if (!url?.startsWith('blob:') || typeof globalThis.URL?.revokeObjectURL !== 'function') return; globalThis.URL.revokeObjectURL(url); } function resetPendingMedia() { state.pendingAudioFile = null; state.pendingAudioHandle = null; state.pendingImages.forEach(releasePendingPreview); state.pendingImages = []; state.selectedMediaKey = null; if (byId(ids.poiAudio)) byId(ids.poiAudio).value = ''; if (byId(ids.poiImages)) byId(ids.poiImages).value = ''; } function existingAudioUrl(poi) { return poi?.audio?.url || poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null; } function imagePreviewUrl(image) { return image?.dataUrl || image?.url || null; } function mediaPreviewCard({ key, kind, label, previewUrl = null, source, mediaId = '', mediaIndex = '' }) { const card = document.createElement('div'); card.className = classes.mediaPreviewCard; card.dataset.selected = String(state.selectedMediaKey === key); const select = document.createElement('button'); select.className = classes.mediaPreviewSelect; select.dataset.editorAction = 'select-media'; select.dataset.mediaKey = key; select.type = 'button'; select.setAttribute('aria-expanded', String(state.selectedMediaKey === key)); select.setAttribute('aria-label', ns.I18n.t("editor.remove-media-label", { value1: label, value2: state.selectedMediaKey === key ? 'ausblenden' : 'anzeigen' })); if (kind === 'image' && previewUrl) { const image = document.createElement('img'); image.className = classes.mediaPreviewImage; image.src = previewUrl; image.alt = ''; select.appendChild(image); } else { const mediaSymbol = document.createElement('span'); mediaSymbol.className = classes.mediaPreviewAudio; mediaSymbol.setAttribute('aria-hidden', 'true'); mediaSymbol.textContent = kind === 'image' ? '▧' : '♪'; select.appendChild(mediaSymbol); } appendText(select, 'small', label); const remove = document.createElement('button'); remove.className = classes.mediaRemove; remove.dataset.editorAction = 'remove-media'; remove.dataset.mediaId = text(mediaId); remove.dataset.mediaIndex = text(mediaIndex); remove.dataset.mediaKey = key; remove.dataset.mediaKind = kind; remove.dataset.mediaSource = source; remove.hidden = state.selectedMediaKey !== key; remove.type = 'button'; remove.textContent = '−'; remove.title = `${label} entfernen`; remove.setAttribute('aria-label', `${label} entfernen`); card.append(select, remove); return card; } function renderPoiMediaPreview(poi = state.currentPoi) { const preview = byId(ids.poiMediaPreview); if (!preview) return; preview.textContent = ''; if (poi?.audio || existingAudioUrl(poi)) { preview.appendChild(mediaPreviewCard({ key: 'existing-audio', kind: 'audio', label: poi?.audio?.name || 'Tonaufnahme', source: 'existing' })); } if (state.pendingAudioFile) { preview.appendChild(mediaPreviewCard({ key: 'pending-audio', kind: 'audio', label: state.pendingAudioFile.name || ns.I18n.t("editor.new-audio"), 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 || ns.I18n.t("editor.image-number", { value1: 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 || ns.I18n.t("editor.new-image", { value1: index + 1 }), mediaIndex: index, previewUrl: pending.previewUrl || null, source: 'pending' })); }); } function updatePoiMediaStatus(poi = state.currentPoi) { const status = byId(ids.poiMediaStatus); if (!status) return; const existingAudio = poi?.audio || existingAudioUrl(poi) ? 1 : 0; const existingImages = Array.isArray(poi?.images) ? poi.images.length : 0; const audioCount = existingAudio + (state.pendingAudioFile ? 1 : 0); const imageCount = existingImages + state.pendingImages.length; const parts = []; if (audioCount) parts.push(ns.I18n.t('media.audio.count', { count: audioCount })); if (imageCount) parts.push(ns.I18n.t('media.image.count', { count: imageCount })); status.textContent = parts.length ? ns.I18n.t("editor.remove-media-hint", { value1: parts.join(' · ') }) : ns.I18n.t("editor.no-media"); } function openFilePicker(inputId) { const input = byId(inputId); if (input && typeof input.click === 'function') input.click(); } function pickerOptions(kind) { if (kind === 'audio') { return { multiple: false, types: [{ description: ns.I18n.t("editor.audio-section"), accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }] }; } return { multiple: true, types: [{ description: ns.I18n.t("editor.image-section"), accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }] }; } async function pickWithFileSystemAccess(kind) { if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') return false; let handles; try { handles = await globalThis.showOpenFilePicker(pickerOptions(kind)); } catch (error) { return error?.name === 'AbortError'; } if (kind === 'audio') { const handle = handles?.[0]; if (!handle) return true; state.pendingAudioHandle = handle; state.pendingAudioFile = await handle.getFile(); } else { for (const handle of handles || []) { const file = await handle.getFile(); state.pendingImages.push({ file, handle, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }); } } state.selectedMediaKey = null; renderPoiMediaPreview(); updatePoiMediaStatus(); return true; } function chooseMedia(kind, inputId) { if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') { openFilePicker(inputId); return null; } return pickWithFileSystemAccess(kind).then(handled => { if (!handled) openFilePicker(inputId); }); } function fillPoiForm(poi) { resetPendingMedia(); byId(ids.poiId).value = poiId(poi); byId(ids.poiTitle).value = poiTitle(poi) === ns.I18n.t("editor.untitled-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.pendingAudioHandle = null; state.pendingAudioFile = byId(ids.poiAudio)?.files?.[0] || null; state.selectedMediaKey = null; if (byId(ids.poiAudio)) byId(ids.poiAudio).value = ''; renderPoiMediaPreview(); updatePoiMediaStatus(); } async function handleImageSelection() { const files = Array.from(byId(ids.poiImages)?.files || []); for (const file of files) { state.pendingImages.push({ file, handle: null, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }); } state.selectedMediaKey = null; if (byId(ids.poiImages)) byId(ids.poiImages).value = ''; renderPoiMediaPreview(); updatePoiMediaStatus(); } async function loadRoute(route) { const id = routeId(route); if (!id) return; if (state.currentRoute && (routeSource(route) !== state.currentSource || routeId(state.currentRoute) !== id)) { ns.RouteRecorder?.endEditing(); } setStatus(ns.I18n.t("editor.route.loading")); state.currentSource = routeSource(route); if (state.currentSource === 'local') { const routePackage = ns.LocalRoutes.get(id); if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing")); state.currentRoute = { ...routePackage.route, id: routePackage.id, gpx: routePackage.gpx, _source: 'local' }; if (byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(id); 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: state.currentSource }; state.pois = poiArray(detail); } state.currentPoi = null; fillRouteForm(state.currentRoute); renderPoiList(); showRouteView(); setStatus(''); } function newRoute() { ns.RouteRecorder?.endEditing(); state.currentSource = 'local'; state.currentRoute = { id: '', name: '', description: '', gpx: ns.LocalRoutes.emptyGpx(ns.I18n.t("editor.new-route")), _source: 'local' }; state.currentPoi = null; state.pois = []; fillRouteForm(state.currentRoute); renderPoiList(); showRouteView(); setStatus(ns.I18n.t("editor.draft-status")); } 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('creator_id', state.currentRoute.creator_id || ns.LocalRoutes.creatorId()); if (file) data.append('gpx', file); return data; } async function saveLocalRoute(values) { const file = byId(ids.routeGpx)?.files?.[0]; let gpx = ns.LocalRoutes.get?.(routeId(state.currentRoute))?.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; } gpx = ns.RouteRecorder?.editGpx(gpx, values.name) ?? gpx; const isNew = !routeId(state.currentRoute); 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'; if (isNew && byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(saved.id); state.pois = saved.pois; await loadRoutes(); await loadRoute({ id: saved.id, _source: 'local' }); setStatus(ns.I18n.t("editor.saved")); } async function saveServerRoute(values) { const id = routeId(state.currentRoute); if (!id) throw new Error(ns.I18n.t("editor.error.create-locally")); const file = byId(ids.routeGpx)?.files?.[0]; values = { ...values, creator_id: state.currentRoute.creator_id || ns.LocalRoutes.creatorId() }; 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: state.currentSource }; await loadRoutes(); await loadRoute({ id, _source: state.currentSource }); setStatus(ns.I18n.t("editor.saved")); } async function saveRoute() { const values = routeFromForm(); if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name")); if (state.currentSource === 'local') await saveLocalRoute(values); else await saveServerRoute(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 || ns.I18n.t("editor.untitled-route")); data.append('description', routePackage.route?.description || ''); data.append('creator_id', routePackage.route?.creator_id || ns.LocalRoutes.creatorId()); const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage); const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' }); data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name)); return data; } async function storedMediaBlob(media) { const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true }); if (!blob) { throw new Error(ns.I18n.t("editor.error.send-file", { value1: media?.name || '' }).trim()); } return blob; } async function uploadStoredMedia(path, fieldName, media, fallbackName, method = 'POST') { const blob = await storedMediaBlob(media); const data = new FormData(); data.append(fieldName, blob, media.name || fallbackName); if (fieldName === 'picture') data.append('caption', media.caption || ''); return sendForm(path, method, data); } function checkpointLocalUpload(routePackage) { const latest = ns.LocalRoutes.get(routePackage.id); // GPS samples may arrive while media requests are pending. if (latest) routePackage.gpx = latest.gpx; return ns.LocalRoutes.save(routePackage); } function submittedStatus(existing) { if (!existing) return 'removed'; return existing.status === 'deleted' ? 'rejected' : null; } async function findSubmittedRoute(id) { try { const payload = await readJson(`/routes/${encodeURIComponent(id)}?includeDeleted=true`); return payload.route || payload; } catch (error) { if (error.status === 404) return null; throw error; } } function clearSubmittedReferences(routePackage) { routePackage.route.serverRouteId = null; routePackage.route.uploadedAt = null; for (const poi of routePackage.pois || []) { delete poi.serverPoiId; delete poi.serverAudio; for (const image of poi.images || []) delete image.serverPictureId; } checkpointLocalUpload(routePackage); } async function prepareServerRoute(routePackage) { const id = routePackage.route.serverRouteId; const replacedSubmission = Boolean(id || routePackage.route.submissionStatus); if (id) { const existing = await findSubmittedRoute(id); if (!existing || existing.status === 'deleted') { // Preserve the old trash entry; submit a new draft with fresh references. routePackage.route.submissionStatus = submittedStatus(existing); clearSubmittedReferences(routePackage); } else { if (existing.activatedAt || existing.activated_at) { throw new Error(ns.I18n.t("editor.read-only-hint")); } await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage)); return { id, previousPois: existing.pois || [] }; } } const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage)); const createdId = routeId(payload.route || payload); if (!createdId) throw new Error(ns.I18n.t("editor.error.send-route")); routePackage.route.serverRouteId = createdId; routePackage.route.submissionStatus = null; checkpointLocalUpload(routePackage); return { id: createdId, previousPois: [], recreated: replacedSubmission }; } async function syncLocalPoi(base, poi, index, routePackage) { const payload = await sendJson(poi.serverPoiId ? `${base}/${poi.serverPoiId}` : base, poi.serverPoiId ? 'PUT' : 'POST', { description: poi.description || '', lat: poi.lat, lon: poi.lon, sequence: index, title: poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }), triggerRadiusM: poi.triggerRadiusM || 25 }); poi.serverPoiId = poiId(payload.poi || payload); if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' })); checkpointLocalUpload(routePackage); } async function syncLocalAudio(mediaBase, poi, oldPoi, routePackage) { if (poi.audio) { await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin', oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST'); poi.serverAudio = true; checkpointLocalUpload(routePackage); } else if (oldPoi?.audioUrl) { await sendDelete(`${mediaBase}/audio`); poi.serverAudio = false; checkpointLocalUpload(routePackage); } } async function removeMissingServerItems(previous, current, serverIdKey, base) { const retained = new Set(current.map(item => String(item[serverIdKey]))); for (const item of previous) { if (!retained.has(String(item.id))) await sendDelete(`${base}/${item.id}`); } } async function syncLocalPictures(mediaBase, poi, oldPoi, routePackage) { const images = poi.images || []; for (const [index, image] of images.entries()) { const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`; const picture = await uploadStoredMedia(path, 'picture', image, `bild-${index + 1}.bin`, image.serverPictureId ? 'PUT' : 'POST'); image.serverPictureId = (picture.picture || picture).id; if (!image.serverPictureId) throw new Error(ns.I18n.t("editor.error.send-file", { value1: image.name || '' })); checkpointLocalUpload(routePackage); } await removeMissingServerItems(oldPoi?.images || [], images, 'serverPictureId', `${mediaBase}/pictures`); } async function createServerRouteFromLocal(routePackage) { const { id, previousPois, recreated } = await prepareServerRoute(routePackage); const base = `/routes/${encodeURIComponent(id)}/pois`; const orderedPois = [...(routePackage.pois || [])] .sort((left, right) => Number(left.sequence) - Number(right.sequence)); for (const [index, poi] of orderedPois.entries()) { await syncLocalPoi(base, poi, index, routePackage); const mediaBase = `${base}/${poi.serverPoiId}`; const oldPoi = previousPois.find(old => String(old.id) === String(poi.serverPoiId)); await syncLocalAudio(mediaBase, poi, oldPoi, routePackage); await syncLocalPictures(mediaBase, poi, oldPoi, routePackage); } await removeMissingServerItems(previousPois, orderedPois, 'serverPoiId', base); return { id, recreated }; } async function uploadLocalRoute() { if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft")); const values = routeFromForm(); if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name")); const uploadButton = byId(ids.routeUpload); setDisabled(uploadButton, true); try { await saveLocalRoute(values); setStatus(ns.I18n.t("editor.submitting")); const localId = routeId(state.currentRoute); const routePackage = ns.LocalRoutes.get(localId); if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing")); const { id: serverRouteId, recreated } = 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(ns.I18n.t(recreated ? 'editor.resubmit-success' : 'editor.submit-success')); } finally { // Keep acknowledged server IDs even if a later media request failed. const latest = ns.LocalRoutes.get(routeId(state.currentRoute)); if (latest) { state.currentRoute = { ...latest.route, id: latest.id, gpx: latest.gpx, _source: 'local' }; state.pois = latest.pois; fillRouteForm(state.currentRoute); } setDisabled(uploadButton, false); } } function currentPosition() { if (ns.RouteRecorder?.routeId === routeId(state.currentRoute)) return ns.RouteRecorder.position; return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null; } function newPoi() { ns.GroupPoints?.release(); releasePoiPreview(); const position = currentPosition(); const lat = position?.lat ?? null; const lon = position?.lon ?? null; const accuracy = Number(position?.accuracy); state.currentPoi = null; state.groupDeletions = []; state.groupDeletePoint = false; const identifier = state.currentSource === 'local' ? nextLocalPoiId() : null; fillPoiForm({ ...(identifier == null ? {} : { id: identifier }), lat, lon, triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25 }); byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.add-station"); showPoiView(); } async function editPoi(poi) { if (!poi) return; releasePoiPreview(); let draft; if (state.currentSource === 'group' && ns.GroupPoints) { const opened = await ns.GroupPoints.begin(routeId(state.currentRoute), poi); if (!opened) return; poi = opened.poi; draft = opened.draft; } state.groupDeletions = draft?.media?.deletions || []; state.groupDeletePoint = Boolean(draft?.media?.deletePoint); state.currentPoi = poi; fillPoiForm(draft ? { ...poi, ...draft.values } : poi); if (draft) { state.pendingAudioFile = draft.media?.audio || null; state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) })); const hiddenImages = new Set(state.groupDeletions.map(item => item.path)); state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.has(`pictures/${image.id}`)), ...(hiddenImages.has('audio') ? { audioUrl: null } : {}) }; renderPoiMediaPreview(); updatePoiMediaStatus(); setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored')); } byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station"); showPoiView(); if (state.currentSource !== 'local') return true; const requestGeneration = poiMediaGeneration; const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true }); if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) { ns.LocalRoutes.releasePoiMedia(hydrated); return; } if (!hydrated) return; previewPoi = hydrated; state.currentPoi = hydrated; // Preserve any text edits and newly selected files while existing previews load. renderPoiMediaPreview(); updatePoiMediaStatus(); } async function mediaFromLocalForm(existingPoi, poiIdentifier) { const routeIdentifier = routeId(state.currentRoute); if (!routeIdentifier) throw new Error(ns.I18n.t("editor.error.save-route-first")); const storedNow = []; const replacedAfterSave = []; let audio = existingPoi?.audio || null; if (state.pendingAudioFile) { const mediaId = `${poiIdentifier}-audio`; const storedAudio = await ns.LocalRoutes.storeMediaFile( routeIdentifier, poiIdentifier, 'audio', mediaId, state.pendingAudioFile, state.pendingAudioHandle ); storedNow.push(storedAudio); if (audio) replacedAfterSave.push(audio); audio = storedAudio; } const images = [...(existingPoi?.images || [])]; for (const pending of state.pendingImages) { const mediaId = `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`; const image = await ns.LocalRoutes.storeMediaFile( routeIdentifier, poiIdentifier, 'image', mediaId, pending.file, pending.handle ); images.push({ ...image, caption: '' }); storedNow.push(image); } return { audio, images, storedNow, replacedAfterSave }; } async function persistLocalCurrentRoute() { const id = routeId(state.currentRoute); if (!id) throw new Error(ns.I18n.t("editor.error.save-route-first")); const currentPoiId = poiId(state.currentPoi); const saved = ns.LocalRoutes.save({ id, gpx: ns.LocalRoutes.get?.(routeId(state.currentRoute))?.gpx || state.currentRoute.gpx, route: state.currentRoute, pois: state.pois }); const previousPoi = state.currentPoi; 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; if (state.currentPoi && previousPoi) { state.currentPoi = { ...state.currentPoi, audio: previousPoi.audio, images: previousPoi.images }; } return saved; } async function saveLocalPoi(values) { const currentId = numericPoiId(byId(ids.poiId).value); const identifier = currentId ?? nextLocalPoiId(); byId(ids.poiId).value = String(identifier); const existing = state.pois.find(poi => numericPoiId(poi?.id ?? poi?.poiId) === identifier) || state.currentPoi; const media = await mediaFromLocalForm(existing, identifier); const savedPoi = { ...existing, ...values, audio: media.audio, images: media.images, id: identifier, sequence: existing?.sequence ?? state.pois.length }; const index = state.pois.findIndex(poi => numericPoiId(poi?.id ?? poi?.poiId) === identifier); if (index >= 0) state.pois[index] = savedPoi; else state.pois.push(savedPoi); try { await persistLocalCurrentRoute(); } catch (error) { await Promise.allSettled(media.storedNow.map(ns.LocalRoutes.removeMedia)); throw error; } await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia)); resetPendingMedia(); setStatus(ns.I18n.t("editor.station-saved-with-media")); 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); if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); } } for (const pending of state.pendingImages) { await uploadOne(`${basePath}/pictures`, 'picture', pending.file); if (state.currentSource === 'group') { state.pendingImages = state.pendingImages.filter(item => item !== pending); releasePendingPreview(pending); await rememberGroupPoint(poiFromForm(), currentPoiId); } } } async function rememberGroupPoint(values, identifier = byId(ids.poiId).value || 'new') { return ns.GroupPoints.remember(state.currentRoute, identifier, values, { audio: state.pendingAudioFile, images: state.pendingImages.map(item => item.file), deletions: state.groupDeletions, deletePoint: state.groupDeletePoint }); } async function prepareGroupPoint(values, currentPoiId) { const draft = await rememberGroupPoint(values); if (!currentPoiId) return true; try { const prepared = await ns.GroupPoints.prepare(draft); if (prepared) { state.groupDeletions = draft.media.deletions; state.currentPoi = prepared.poi; return true; } } catch { /* The complete intent was persisted before trying to acquire the point. */ } setStatus(ns.I18n.t('point-locks.saved-device')); return false; } async function saveGroupPointActions(currentRouteId, currentPoiId, saved, values) { const savedPoiId = poiId(saved) || currentPoiId; if (!currentPoiId) { byId(ids.poiId).value = savedPoiId; state.currentPoi = saved; // Persist the acknowledged ID before any further network request. await rememberGroupPoint(values, savedPoiId); await ns.GroupPoints.remove(currentRouteId, 'new'); await ns.GroupPoints.acceptNew(currentRouteId, saved); } for (const deletion of state.groupDeletions) { await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`); state.groupDeletions = state.groupDeletions.filter(item => item !== deletion); await rememberGroupPoint(values, savedPoiId); } } async function saveServerPoi(values) { if (state.currentSource === 'group' && state.groupDeletePoint) return deleteCurrentPoi(); const currentRouteId = routeId(state.currentRoute); if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first")); const currentPoiId = byId(ids.poiId).value; if (state.currentSource === 'group' && ns.GroupPoints && !await prepareGroupPoint(values, currentPoiId)) return; 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 (state.currentSource === 'group' && ns.GroupPoints) await saveGroupPointActions(currentRouteId, currentPoiId, saved, values); if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId); if (state.currentSource === 'group' && ns.GroupPoints) await ns.GroupPoints.remove(currentRouteId, savedPoiId); setStatus(ns.I18n.t("editor.station-saved")); await loadRoute({ id: currentRouteId, _source: state.currentSource }); } let pointSaveInProgress = false; async function savePoi() { if (pointSaveInProgress) return; pointSaveInProgress = true; byId(ids.dialog).inert = true; try { await savePoiContents(); } finally { pointSaveInProgress = false; byId(ids.dialog).inert = false; } } async function savePoiContents() { const values = poiFromForm(); if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name")); if (state.currentSource === 'local') await saveLocalPoi(values); else { try { await saveServerPoi(values); } catch (error) { if (state.currentSource !== 'group' || !ns.GroupPoints) throw error; // Retain all unacknowledged files and intents, even when the lock changed during upload. await rememberGroupPoint(values); setStatus(ns.I18n.t('point-locks.saved-device')); } } } function updateCurrentPoi(updatedPoi) { const identifier = poiId(updatedPoi); const index = state.pois.findIndex(poi => poiId(poi) === identifier); if (index >= 0) state.pois[index] = updatedPoi; state.currentPoi = updatedPoi; } async function removePendingMedia(kind, mediaIndex) { if (kind === 'audio') { state.pendingAudioFile = null; state.pendingAudioHandle = null; } else { const index = Number.parseInt(mediaIndex, 10); if (Number.isInteger(index)) { const [removed] = state.pendingImages.splice(index, 1); releasePendingPreview(removed); } } state.selectedMediaKey = null; renderPoiMediaPreview(); updatePoiMediaStatus(); } async function removeExistingLocalMedia(kind, mediaIndex) { if (!state.currentPoi) return; const updated = { ...state.currentPoi }; let removedMedia = null; if (kind === 'audio') { removedMedia = updated.audio; updated.audio = null; } else { const index = Number.parseInt(mediaIndex, 10); updated.images = [...(state.currentPoi.images || [])]; if (Number.isInteger(index)) { [removedMedia] = updated.images.splice(index, 1); } } updateCurrentPoi(updated); await persistLocalCurrentRoute(); if (removedMedia) { ns.LocalRoutes.releasePoiMedia({ audio: removedMedia }); await ns.LocalRoutes.removeMedia(removedMedia); } state.selectedMediaKey = null; renderPoiMediaPreview(); updatePoiMediaStatus(); setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed")); } async function removeExistingServerMedia(kind, mediaId) { const currentRouteId = routeId(state.currentRoute); const currentPoiId = poiId(state.currentPoi); if (!currentRouteId || !currentPoiId) throw new Error(ns.I18n.t("editor.error.station-unsaved")); const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`; const updated = { ...state.currentPoi }; if (kind === 'audio') { if (state.currentSource === 'group') state.groupDeletions.push({ path: 'audio' }); else await sendDelete(`${basePath}/audio`); updated.audio = null; updated.audioUrl = null; updated.audio_path = null; } else { if (!mediaId) throw new Error(ns.I18n.t("editor.error.remove-image")); if (state.currentSource === 'group') state.groupDeletions.push({ path: `pictures/${encodeURIComponent(mediaId)}` }); else 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(); if (state.currentSource === 'group') { await rememberGroupPoint(poiFromForm()); setStatus(ns.I18n.t('point-locks.deletion-staged')); } else setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed")); } async function removeMedia(control) { const kind = control.dataset.mediaKind; const source = control.dataset.mediaSource; if (source === 'pending') { await removePendingMedia(kind, control.dataset.mediaIndex); return; } if (state.currentSource === 'local') { await removeExistingLocalMedia(kind, control.dataset.mediaIndex); return; } await removeExistingServerMedia(kind, control.dataset.mediaId); } async function mediaPreviewAction(event) { const control = event.target.closest('[data-editor-action]'); const action = control?.dataset.editorAction; if (action === 'select-media') { const key = control.dataset.mediaKey; state.selectedMediaKey = state.selectedMediaKey === key ? null : key; renderPoiMediaPreview(); return; } if (action === 'remove-media') await removeMedia(control); } async function deleteCurrentPoi() { const currentRouteId = routeId(state.currentRoute); const currentPoiId = poiId(state.currentPoi); if (!currentRouteId || !currentPoiId) return; if (state.currentSource === 'local') { const removedPoi = state.currentPoi; state.pois = state.pois .filter(poi => poiId(poi) !== currentPoiId) .map((poi, sequence) => ({ ...poi, sequence })); state.currentPoi = null; await persistLocalCurrentRoute(); const removedMedia = [removedPoi?.audio, ...(removedPoi?.images || [])].filter(Boolean); await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia)); renderPoiList(); showRouteView(); setStatus(ns.I18n.t("editor.station-removed-and-saved")); return; } if (state.currentSource === 'group' && ns.GroupPoints) { state.groupDeletePoint = true; const draft = await rememberGroupPoint(poiFromForm()); try { if (!await ns.GroupPoints.prepare(draft)) { setStatus(ns.I18n.t('point-locks.saved-device')); return; } await sendDelete(`/routes/${currentRouteId}/pois/${currentPoiId}`); await ns.GroupPoints.remove(currentRouteId, currentPoiId); } catch { setStatus(ns.I18n.t('point-locks.saved-device')); return; } } else await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`); await loadRoute({ id: currentRouteId, _source: state.currentSource }); setStatus(ns.I18n.t("editor.station-removed")); } 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 saveGroupPoiOrder(currentRouteId) { let deferred = false; for (const [index, poi] of state.pois.entries()) { if (await ns.GroupPoints.read(currentRouteId, poi.id)) { deferred = true; continue; } const values = { ...poi, sequence: index }; try { const opened = await ns.GroupPoints.begin(currentRouteId, poi); await ns.GroupPoints.remember(state.currentRoute, poi.id, { ...(opened?.poi || values), sequence: index }, { images: [], audio: null, deletions: [] }); if (!opened) { deferred = true; continue; } await sendJson(`/routes/${currentRouteId}/pois/${poi.id}`, 'PUT', { sequence: index, sortOrder: index }); await ns.GroupPoints.remove(currentRouteId, poi.id); } catch { await ns.GroupPoints.remember(state.currentRoute, poi.id, values, { images: [], audio: null, deletions: [] }); deferred = true; } finally { await ns.GroupPoints.release(); } } setStatus(ns.I18n.t(deferred ? 'point-locks.saved-device' : 'editor.order.saved')); } async function savePoiOrder() { const currentRouteId = routeId(state.currentRoute); if (!currentRouteId) return; if (state.currentSource === 'local') { await persistLocalCurrentRoute(); setStatus(ns.I18n.t("editor.station-order-saved")); return; } if (state.currentSource === 'group' && ns.GroupPoints) return saveGroupPoiOrder(currentRouteId); 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(ns.I18n.t("editor.order.saved")); } function routeForItem(target) { const item = target.closest('[data-route-id]'); const id = item?.dataset.routeId; const source = item?.dataset.routeSource; return state.routes.find(route => routeId(route) === id && routeSource(route) === source); } function poiForItem(target) { const item = target.closest('[data-poi-id]'); return state.pois.find(poi => poiId(poi) === item?.dataset.poiId); } function poiIndexForItem(target) { const item = target.closest('[data-poi-id]'); return state.pois.findIndex(poi => poiId(poi) === item?.dataset.poiId); } async function deleteRouteFromList(route) { const id = routeId(route); if (!id || routeSource(route) !== 'local') return; await ns.LocalRoutes.remove(id); if (routeId(state.currentRoute) === id && state.currentSource === routeSource(route)) { state.currentRoute = null; state.currentSource = null; state.currentPoi = null; state.pois = []; } await loadRoutes(); showListView(); setStatus(ns.I18n.t("editor.route-deleted")); } async function routeListAction(event) { const control = event.target.closest('[data-editor-action]'); const action = control?.dataset.editorAction; const route = routeForItem(event.target); if (!route) return; if (action === 'share-route') { if (shareInProgress) return; shareInProgress = true; try { const code = await ns.Groups.chooseCode(); if (!code) return; let sourceId = routeId(route); if (routeSource(route) === 'local') { if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return; await loadRoute(route); await uploadLocalRoute(); sourceId = state.currentRoute.serverRouteId; } await ns.Groups.share(sourceId, code); } finally { shareInProgress = false; } return; } if (action === 'edit-route') { await loadRoute(route); return; } if (action === 'delete-route' && routeSource(route) === 'local') { await deleteRouteFromList(route); } } async function deletePoiFromList(poi) { if (!poi) return; if (state.currentSource === 'group') { if (!await editPoi(poi)) return; state.groupDeletePoint = true; await savePoi(); return; } state.currentPoi = poi; await deleteCurrentPoi(); } async function poiListAction(event) { const action = event.target.closest('[data-editor-action]')?.dataset.editorAction; if (action === 'edit-poi') { await editPoi(poiForItem(event.target)); return; } if (action === 'delete-poi') { await deletePoiFromList(poiForItem(event.target)); return; } if (action === 'move-poi-up') { swapPois(poiIndexForItem(event.target), -1); return; } if (action === 'move-poi-down') swapPois(poiIndexForItem(event.target), 1); } async function guarded(action) { try { await action(); await ns.StorageUsage?.refresh(); } catch (error) { ns.StorageUsage?.reportError(error); setStatus(ns.UserMessages?.error(error) || ns.I18n.t("editor.error.generic"), true); } } function bind() { for (const eventName of ['input', 'change', 'pointerdown', 'keydown']) { byId(ids.poiForm)?.addEventListener(eventName, () => { if (state.currentSource === 'group') ns.GroupPoints?.noteActivity?.(); }); } document.addEventListener('wegwichtel:point-lock-lost', () => { if (state.currentSource === 'group') setStatus(ns.I18n.t('point-locks.lost')); }); for (const eventName of ['cancel', 'close']) { byId(ids.dialog)?.addEventListener(eventName, event => { if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; } releasePoiPreview(); ns.GroupPoints?.release(); ns.RouteRecorder?.endEditing(); }); } activate(byId(ids.button), () => guarded(openEditor)); activate(byId(ids.close), closeEditor); activate(byId(ids.newRoute), newRoute); activate(byId(ids.routeBack), () => { showListView(); guarded(loadRoutes); }); activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute)); activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms')); activate(byId(ids.addPoi), newPoi); activate(byId(ids.poiAudioAdd), () => guarded(() => chooseMedia('audio', ids.poiAudio))); activate(byId(ids.poiImagesAdd), () => guarded(() => chooseMedia('image', ids.poiImages))); byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection); byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection)); activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event))); activate(byId(ids.poiDelete), () => { if (state.currentSource === 'group') { state.groupDeletePoint = true; guarded(savePoi); } else guarded(deleteCurrentPoi); }); activate(byId(ids.poiCancel), showRouteView); activate(byId(ids.routeList), event => guarded(() => routeListAction(event))); activate(byId(ids.poiList), event => guarded(() => poiListAction(event))); submit(byId(ids.routeForm), () => guarded(saveRoute)); submit(byId(ids.poiForm), () => guarded(savePoi)); } function editorApi() { return Object.freeze({ close: closeEditor, open: openEditor, openNewLocalRoute, openGroup: async route => { await openEditor(); await loadRoute({ ...route, _source: 'group' }); }, openPendingPoint: async draft => { await openEditor(); try { await loadRoute({ ...draft.route, _source: 'group' }); } catch { state.currentSource = 'group'; state.currentRoute = { ...draft.route, _source: 'group' }; state.pois = draft.basePoi ? [draft.basePoi] : []; fillRouteForm(state.currentRoute); renderPoiList(); showRouteView(); } if (draft.poiId === 'new') { newPoi(); fillPoiForm(draft.values); state.pendingAudioFile = draft.media?.audio || null; state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) })); renderPoiMediaPreview(); updatePoiMediaStatus(); } else await editPoi(draft.basePoi || { id: draft.poiId }); }, refresh: loadRoutes }); } function init() { bind(); ns.EditorUi = editorApi(); } ready(init); }(globalThis.Wegwichtel));