Files
Wegwichtel/public/js/editor-ui.js
T
Florian Zumpe 0e4cdfbd54
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s
Sonarqube Scanner / Deploy to Server (push) Successful in 10s
group code popup
2026-10-18 17:24:41 +02:00

1792 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
(function (ns) {
'use strict';
const ids = Object.freeze({
addPoi: 'editor-add-poi',
button: 'editor-button',
close: 'editor-close',
dialog: 'editor-dialog',
dialogTitle: 'editor-dialog-title',
newRoute: 'editor-new-route',
poiAudio: 'editor-poi-audio',
poiAudioAdd: 'editor-poi-audio-add',
poiCancel: 'editor-poi-cancel',
poiDelete: 'editor-poi-delete',
poiDescription: 'editor-poi-description',
poiForm: 'editor-poi-form',
poiFormTitle: 'editor-poi-form-title',
poiFormView: 'editor-poi-form-view',
poiId: 'editor-poi-id',
poiImages: 'editor-poi-images',
poiImagesAdd: 'editor-poi-images-add',
poiLat: 'editor-poi-lat',
poiList: 'editor-poi-list',
poiLon: 'editor-poi-lon',
poiMediaPreview: 'editor-poi-media-preview',
poiMediaStatus: 'editor-poi-media-status',
poiRadius: 'editor-poi-radius',
poiSave: 'editor-poi-save',
poiTitle: 'editor-poi-title',
routeActivated: 'editor-route-activated',
routeBack: 'editor-route-back',
routeDescription: 'editor-route-description',
routeDetailTitle: 'editor-route-detail-title',
routeDetailView: 'editor-route-detail-view',
routeForm: 'editor-route-form',
routeGpx: 'editor-route-gpx',
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() {
void 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() {
void 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.
void refreshPublicRoutes(requestGeneration);
void 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);
void ns.Groups?.refresh?.();
}
async function openNewLocalRoute() {
await openEditor();
newRoute();
}
function closeEditor() {
if (pointSaveInProgress) return;
void 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 saveServerRoute(values);
else await saveLocalRoute(values);
}
function safeGpxFilename(name) {
let base = text(name || 'wegwichtel-route')
.normalize('NFKD')
.replaceAll(/[^a-zA-Z0-9_-]+/g, '-');
while (base.startsWith('-')) base = base.slice(1);
while (base.endsWith('-')) base = base.slice(0, -1);
base = base.slice(0, 80);
return `${base || 'wegwichtel-route'}.gpx`;
}
function localRouteUploadForm(routePackage) {
const data = new FormData();
data.append('name', routePackage.route?.name || 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() {
void 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 = state.groupDeletions.map(item => item.path);
state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.includes(`pictures/${image.id}`)),
...(hiddenImages.includes('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 {
if (ns.Groups.confirmShare && !await ns.Groups.confirmShare(routeName(route))) 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);
} 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();
void 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(); void 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; void guarded(savePoi); }
else void 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));