This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- Wegwichtel editor support: route activation timestamp.
|
||||
-- New routes remain unpublished while activated_at is NULL.
|
||||
-- Run once against the SQLite database if the routes table does not already contain this column.
|
||||
ALTER TABLE routes ADD COLUMN activated_at DATETIME NULL;
|
||||
@@ -0,0 +1,44 @@
|
||||
# Wegwichtel Streckeneditor - erste einfache Fassung
|
||||
|
||||
Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der Streckenauswahl.
|
||||
|
||||
## Enthaltene Dateien
|
||||
|
||||
- `public/index.html`
|
||||
- neuer Abschnitt `Strecken verwalten` mit rundem Plus-Button
|
||||
- neues Dialogfenster `#editor-dialog`
|
||||
- Einbindung von `css/editor.css` und `js/editor-ui.js`
|
||||
- `public/css/editor.css`
|
||||
- eigenständige Editor-Styles, damit `app.css` nicht erneut ersetzt werden muss
|
||||
- `public/js/editor-ui.js`
|
||||
- Editor-Dialog, Streckenliste, Streckendetailformular, POI-Formular
|
||||
- POI-Sortierung per Pfeil nach oben/unten
|
||||
- Upload einer Audiodatei und beliebig vieler Bilder pro POI
|
||||
- `database/migrations/20260901_add_route_activated_at.sql`
|
||||
- ergänzt `routes.activated_at DATETIME NULL`
|
||||
|
||||
## Datenbank
|
||||
|
||||
Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist.
|
||||
Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt.
|
||||
|
||||
SQLite-Migration einmalig ausführen, falls die Spalte noch nicht existiert:
|
||||
|
||||
```sh
|
||||
sqlite3 <pfad-zur-wegwichtel-db> < database/migrations/20260901_add_route_activated_at.sql
|
||||
```
|
||||
|
||||
## Erwartete API-Endpunkte
|
||||
|
||||
Der Editor nutzt die vorhandene REST-Struktur der Anwendung:
|
||||
|
||||
- `GET /api/routes`
|
||||
- `GET /api/routes/:routeId`
|
||||
- `POST /api/routes`
|
||||
- `PUT /api/routes/:routeId`
|
||||
- `POST /api/routes/:routeId/pois`
|
||||
- `PUT /api/routes/:routeId/pois/:poiId`
|
||||
- `POST /api/routes/:routeId/pois/:poiId/audio`
|
||||
- `POST /api/routes/:routeId/pois/:poiId/images`
|
||||
|
||||
Schreibende Zugriffe bleiben damit an die bestehende serverseitige Absicherung gebunden.
|
||||
@@ -0,0 +1,229 @@
|
||||
.editor-entry-panel {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid rgba(49, 93, 58, .22);
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 .5rem 1.4rem rgba(20, 40, 24, .08);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 1.4rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.editor-entry-panel h2,
|
||||
.editor-entry-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-entry-panel p {
|
||||
color: #4e604f;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
.editor-button,
|
||||
.editor-round-button {
|
||||
align-items: center;
|
||||
background: #315d3a;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
height: 3rem;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
min-width: 3rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-button:focus-visible,
|
||||
.editor-round-button:focus-visible,
|
||||
.editor-primary-button:focus-visible,
|
||||
.editor-secondary-button:focus-visible,
|
||||
.editor-icon-button:focus-visible {
|
||||
outline: .2rem solid #f2ca6b;
|
||||
outline-offset: .15rem;
|
||||
}
|
||||
|
||||
.editor-dialog {
|
||||
border: 0;
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 1.4rem 3rem rgba(20, 32, 22, .28);
|
||||
max-height: min(90vh, 55rem);
|
||||
max-width: min(92vw, 48rem);
|
||||
padding: 0;
|
||||
width: 48rem;
|
||||
}
|
||||
|
||||
.editor-dialog::backdrop {
|
||||
background: rgba(21, 36, 24, .42);
|
||||
}
|
||||
|
||||
.editor-dialog-shell {
|
||||
background: #f8f5eb;
|
||||
color: #233326;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: min(90vh, 55rem);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.editor-dialog-header,
|
||||
.editor-toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.editor-dialog-header h2,
|
||||
.editor-toolbar h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-dialog-header h2,
|
||||
.editor-toolbar-back h3 {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#editor-close {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(49, 93, 58, .28);
|
||||
border-radius: 999px;
|
||||
color: #233326;
|
||||
display: inline-flex;
|
||||
font-size: 1.5rem;
|
||||
height: 2.4rem;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.editor-status {
|
||||
background: #fff8d7;
|
||||
border: 1px solid rgba(125, 92, 21, .25);
|
||||
border-radius: .8rem;
|
||||
margin: 0;
|
||||
padding: .7rem .85rem;
|
||||
}
|
||||
|
||||
.editor-view[hidden],
|
||||
.editor-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-route-list,
|
||||
.editor-poi-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .65rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-poi-list {
|
||||
counter-reset: editor-poi;
|
||||
}
|
||||
|
||||
.editor-route-item,
|
||||
.editor-poi-item {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(49, 93, 58, .18);
|
||||
border-radius: .9rem;
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
justify-content: space-between;
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.editor-route-item strong,
|
||||
.editor-poi-item strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-route-item small,
|
||||
.editor-poi-item small,
|
||||
.editor-muted {
|
||||
color: #607160;
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(49, 93, 58, .18);
|
||||
border-radius: .9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
padding: .9rem;
|
||||
}
|
||||
|
||||
.editor-form input,
|
||||
.editor-form textarea {
|
||||
border: 1px solid rgba(49, 93, 58, .35);
|
||||
border-radius: .65rem;
|
||||
font: inherit;
|
||||
padding: .65rem .75rem;
|
||||
}
|
||||
|
||||
.editor-form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor-coordinate-grid {
|
||||
display: grid;
|
||||
gap: .55rem;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.editor-primary-button,
|
||||
.editor-secondary-button,
|
||||
.editor-icon-button {
|
||||
border-radius: .75rem;
|
||||
font: inherit;
|
||||
min-height: 2.4rem;
|
||||
padding: .55rem .85rem;
|
||||
}
|
||||
|
||||
.editor-primary-button {
|
||||
background: #315d3a;
|
||||
border: 1px solid #315d3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.editor-secondary-button,
|
||||
.editor-icon-button {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(49, 93, 58, .32);
|
||||
color: #233326;
|
||||
}
|
||||
|
||||
.editor-icon-row {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.editor-icon-button {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-width: 2.4rem;
|
||||
}
|
||||
|
||||
.editor-poi-toolbar {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 42rem) {
|
||||
.editor-coordinate-grid {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="css/bootstrap.css?v=0.12.14">
|
||||
<link rel="stylesheet" href="vendor/jquery-ui/jquery-ui-1.14.2.min.css?v=0.12.14">
|
||||
<link rel="stylesheet" href="css/app.css?v=0.12.14">
|
||||
<link rel="stylesheet" href="css/editor.css?v=0.12.14">
|
||||
</head>
|
||||
<body>
|
||||
<div class="info-control">
|
||||
@@ -95,6 +96,16 @@
|
||||
<li><p>Routen werden geladen …</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="editor-entry-panel" aria-labelledby="editor-entry-title">
|
||||
<div>
|
||||
<h2 id="editor-entry-title">Strecken verwalten</h2>
|
||||
<p>Strecken, Stationen, Bilder und Audiodateien bearbeiten.</p>
|
||||
</div>
|
||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckeneditor öffnen" title="Streckeneditor öffnen">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section id="route-page" class="app-page" hidden>
|
||||
@@ -308,6 +319,97 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
|
||||
<dialog id="editor-dialog" class="editor-dialog" aria-labelledby="editor-dialog-title">
|
||||
<div class="editor-dialog-shell">
|
||||
<header class="editor-dialog-header">
|
||||
<h2 id="editor-dialog-title">Streckeneditor</h2>
|
||||
<button id="editor-close" type="button" aria-label="Streckeneditor schließen" title="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<p id="editor-status" class="editor-status" role="status" aria-live="polite" hidden></p>
|
||||
|
||||
<section id="editor-route-list-view" class="editor-view" aria-labelledby="editor-route-list-title">
|
||||
<div class="editor-toolbar">
|
||||
<h3 id="editor-route-list-title">Vorhandene Strecken</h3>
|
||||
<button id="editor-new-route" class="editor-round-button" type="button" aria-label="Neue Strecke anlegen" title="Neue Strecke anlegen">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
|
||||
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
|
||||
<div class="editor-toolbar editor-toolbar-back">
|
||||
<button id="editor-route-back" class="editor-secondary-button" type="button">Zurück</button>
|
||||
<h3 id="editor-route-detail-title">Streckendetails</h3>
|
||||
</div>
|
||||
|
||||
<form id="editor-route-form" class="editor-form">
|
||||
<input id="editor-route-id" type="hidden">
|
||||
<label for="editor-route-name">Name der Strecke</label>
|
||||
<input id="editor-route-name" type="text" autocomplete="off" required>
|
||||
|
||||
<label for="editor-route-description">Beschreibung</label>
|
||||
<textarea id="editor-route-description" rows="4"></textarea>
|
||||
|
||||
<label for="editor-route-gpx">GPX-Datei optional ersetzen oder ergänzen</label>
|
||||
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
||||
|
||||
<p id="editor-route-activated" class="editor-muted">Neue Strecken bleiben unveröffentlicht, bis sie manuell in der Datenbank aktiviert werden.</p>
|
||||
|
||||
<button id="editor-route-save" class="editor-primary-button" type="submit">Strecke speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="editor-toolbar editor-poi-toolbar">
|
||||
<h3>Stationen</h3>
|
||||
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="POI hinzufügen" title="POI hinzufügen">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
<ol id="editor-poi-list" class="editor-poi-list"></ol>
|
||||
</section>
|
||||
|
||||
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
|
||||
<div class="editor-toolbar editor-toolbar-back">
|
||||
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button>
|
||||
<h3 id="editor-poi-form-title">Station bearbeiten</h3>
|
||||
</div>
|
||||
|
||||
<form id="editor-poi-form" class="editor-form">
|
||||
<input id="editor-poi-id" type="hidden">
|
||||
|
||||
<label for="editor-poi-title">Titel</label>
|
||||
<input id="editor-poi-title" type="text" autocomplete="off" required>
|
||||
|
||||
<label for="editor-poi-description">Text</label>
|
||||
<textarea id="editor-poi-description" rows="6"></textarea>
|
||||
|
||||
<div class="editor-coordinate-grid">
|
||||
<label for="editor-poi-lat">Breite</label>
|
||||
<input id="editor-poi-lat" type="number" inputmode="decimal" step="0.000001">
|
||||
|
||||
<label for="editor-poi-lon">Länge</label>
|
||||
<input id="editor-poi-lon" type="number" inputmode="decimal" step="0.000001">
|
||||
|
||||
<label for="editor-poi-radius">Auslöseradius in Metern</label>
|
||||
<input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25">
|
||||
</div>
|
||||
|
||||
<label for="editor-poi-audio">Audiodatei optional hochladen</label>
|
||||
<input id="editor-poi-audio" type="file" accept="audio/*">
|
||||
|
||||
<label for="editor-poi-images">Bilder optional hochladen</label>
|
||||
<input id="editor-poi-images" type="file" accept="image/*" multiple>
|
||||
|
||||
<button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="js/editor-ui.js?v=0.12.14"></script>
|
||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
||||
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
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',
|
||||
entryTitle: 'editor-entry-title',
|
||||
newRoute: 'editor-new-route',
|
||||
poiAudio: 'editor-poi-audio',
|
||||
poiCancel: 'editor-poi-cancel',
|
||||
poiDescription: 'editor-poi-description',
|
||||
poiForm: 'editor-poi-form',
|
||||
poiFormTitle: 'editor-poi-form-title',
|
||||
poiFormView: 'editor-poi-form-view',
|
||||
poiId: 'editor-poi-id',
|
||||
poiImages: 'editor-poi-images',
|
||||
poiLat: 'editor-poi-lat',
|
||||
poiList: 'editor-poi-list',
|
||||
poiLon: 'editor-poi-lon',
|
||||
poiRadius: 'editor-poi-radius',
|
||||
poiSave: 'editor-poi-save',
|
||||
poiTitle: 'editor-poi-title',
|
||||
routeActivated: 'editor-route-activated',
|
||||
routeBack: 'editor-route-back',
|
||||
routeDescription: 'editor-route-description',
|
||||
routeDetailTitle: 'editor-route-detail-title',
|
||||
routeDetailView: 'editor-route-detail-view',
|
||||
routeForm: 'editor-route-form',
|
||||
routeGpx: 'editor-route-gpx',
|
||||
routeId: 'editor-route-id',
|
||||
routeList: 'editor-route-list',
|
||||
routeListTitle: 'editor-route-list-title',
|
||||
routeListView: 'editor-route-list-view',
|
||||
routeName: 'editor-route-name',
|
||||
routeSave: 'editor-route-save',
|
||||
status: 'editor-status',
|
||||
title: 'editor-dialog-title'
|
||||
});
|
||||
|
||||
const state = {
|
||||
currentPoi: null,
|
||||
currentRoute: null,
|
||||
pois: [],
|
||||
routes: []
|
||||
};
|
||||
|
||||
function ready(callback) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function routeId(route) {
|
||||
return text(route?.id ?? route?.routeId);
|
||||
}
|
||||
|
||||
function poiId(poi) {
|
||||
return text(poi?.id ?? poi?.poiId);
|
||||
}
|
||||
|
||||
function routeName(route) {
|
||||
return text(route?.name || route?.title || 'Unbenannte Strecke');
|
||||
}
|
||||
|
||||
function poiTitle(poi) {
|
||||
return text(poi?.title || poi?.name || 'Unbenannte Station');
|
||||
}
|
||||
|
||||
function apiBase() {
|
||||
const base = ns.Config?.apiBase || '/api';
|
||||
return base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
return `${apiBase()}${path}`;
|
||||
}
|
||||
|
||||
function setHidden(element, hidden) {
|
||||
if (element) element.hidden = hidden;
|
||||
}
|
||||
|
||||
function setStatus(message, isError = false) {
|
||||
const status = byId(ids.status);
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.hidden = !message;
|
||||
status.dataset.kind = isError ? 'error' : 'notice';
|
||||
}
|
||||
|
||||
function showListView() {
|
||||
setHidden(byId(ids.routeListView), false);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
setHidden(byId(ids.poiFormView), true);
|
||||
}
|
||||
|
||||
function showRouteView() {
|
||||
setHidden(byId(ids.routeListView), true);
|
||||
setHidden(byId(ids.routeDetailView), false);
|
||||
setHidden(byId(ids.poiFormView), true);
|
||||
}
|
||||
|
||||
function showPoiView() {
|
||||
setHidden(byId(ids.routeListView), true);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
setHidden(byId(ids.poiFormView), false);
|
||||
}
|
||||
|
||||
function isActivation(event) {
|
||||
if (event.type === 'pointerup') {
|
||||
if (event.isPrimary === false) return false;
|
||||
return event.pointerType !== 'mouse' || event.button === 0;
|
||||
}
|
||||
return event.type === 'keydown' && (event.key === 'Enter' || event.key === ' ');
|
||||
}
|
||||
|
||||
function activate(element, handler) {
|
||||
if (!element) return;
|
||||
element.addEventListener('pointerup', event => {
|
||||
if (!isActivation(event)) return;
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
});
|
||||
element.addEventListener('keydown', event => {
|
||||
if (!isActivation(event)) return;
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
});
|
||||
}
|
||||
|
||||
function submit(form, handler) {
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
async function sendJson(path, method, body) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method
|
||||
});
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
async function sendForm(path, method, formData) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
method
|
||||
});
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
async function handleResponse(response) {
|
||||
if (response.ok) return parseJson(response);
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
const raw = await response.text();
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function routeArray(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.routes)) return payload.routes;
|
||||
if (Array.isArray(payload?.items)) return payload.items;
|
||||
return [];
|
||||
}
|
||||
|
||||
function poiArray(route) {
|
||||
if (Array.isArray(route?.pois)) return route.pois;
|
||||
if (Array.isArray(route?.pointsOfInterest)) return route.pointsOfInterest;
|
||||
return [];
|
||||
}
|
||||
|
||||
function appendText(parent, tagName, value) {
|
||||
const child = document.createElement(tagName);
|
||||
child.textContent = value;
|
||||
parent.appendChild(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function iconButton(label, symbol, action) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'editor-icon-button';
|
||||
button.dataset.editorAction = action;
|
||||
button.textContent = symbol;
|
||||
button.title = label;
|
||||
button.type = 'button';
|
||||
button.setAttribute('aria-label', label);
|
||||
return button;
|
||||
}
|
||||
|
||||
function routeItem(route) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-route-item';
|
||||
item.dataset.routeId = routeId(route);
|
||||
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', routeName(route));
|
||||
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'editor-icon-row';
|
||||
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
||||
|
||||
item.append(label, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function poiItem(poi, index) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-poi-item';
|
||||
item.dataset.poiId = poiId(poi);
|
||||
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', poiTitle(poi));
|
||||
appendText(label, 'small', `Position ${index + 1}`);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'editor-icon-row';
|
||||
actions.appendChild(iconButton('Station nach oben schieben', '↑', 'move-poi-up'));
|
||||
actions.appendChild(iconButton('Station nach unten schieben', '↓', 'move-poi-down'));
|
||||
actions.appendChild(iconButton('Station bearbeiten', '✎', 'edit-poi'));
|
||||
|
||||
item.append(label, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderRouteList() {
|
||||
const list = byId(ids.routeList);
|
||||
if (!list) return;
|
||||
list.textContent = '';
|
||||
if (!state.routes.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-route-item';
|
||||
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
state.routes.forEach(route => list.appendChild(routeItem(route)));
|
||||
}
|
||||
|
||||
function renderPoiList() {
|
||||
const list = byId(ids.poiList);
|
||||
if (!list) return;
|
||||
list.textContent = '';
|
||||
if (!state.pois.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-poi-item';
|
||||
appendText(item, 'span', 'Noch keine Stationen angelegt.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
setStatus('Strecken werden geladen …');
|
||||
const payload = await readJson('/routes');
|
||||
state.routes = routeArray(payload);
|
||||
renderRouteList();
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
async function openEditor() {
|
||||
const dialog = byId(ids.dialog);
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) {
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
}
|
||||
showListView();
|
||||
await guarded(loadRoutes);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
const dialog = byId(ids.dialog);
|
||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||
else if (dialog?.open) dialog.removeAttribute('open');
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function routeFromForm() {
|
||||
return {
|
||||
activatedAt: null,
|
||||
activated_at: null,
|
||||
description: byId(ids.routeDescription)?.value.trim() || '',
|
||||
name: byId(ids.routeName)?.value.trim() || ''
|
||||
};
|
||||
}
|
||||
|
||||
function poiFromForm() {
|
||||
return {
|
||||
description: byId(ids.poiDescription)?.value.trim() || '',
|
||||
lat: numberOrNull(byId(ids.poiLat)?.value),
|
||||
latitude: numberOrNull(byId(ids.poiLat)?.value),
|
||||
lon: numberOrNull(byId(ids.poiLon)?.value),
|
||||
longitude: numberOrNull(byId(ids.poiLon)?.value),
|
||||
radiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||||
sortOrder: state.currentPoi?.sortOrder ?? state.pois.length,
|
||||
title: byId(ids.poiTitle)?.value.trim() || ''
|
||||
};
|
||||
}
|
||||
|
||||
function fillRouteForm(route) {
|
||||
byId(ids.routeId).value = routeId(route);
|
||||
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
||||
byId(ids.routeDescription).value = text(route?.description);
|
||||
byId(ids.routeGpx).value = '';
|
||||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||||
? `Aktiviert am ${route.activatedAt || route.activated_at}. Änderung der Freigabe erfolgt weiterhin manuell in der Datenbank.`
|
||||
: 'Nicht veröffentlicht: activated_at ist leer und wird durch den Editor nicht gesetzt.';
|
||||
}
|
||||
|
||||
function fillPoiForm(poi) {
|
||||
byId(ids.poiId).value = poiId(poi);
|
||||
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannte Station' ? '' : poiTitle(poi);
|
||||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||||
byId(ids.poiRadius).value = text(poi?.radiusM ?? poi?.radius ?? 25);
|
||||
byId(ids.poiAudio).value = '';
|
||||
byId(ids.poiImages).value = '';
|
||||
}
|
||||
|
||||
async function loadRoute(route) {
|
||||
const id = routeId(route);
|
||||
if (!id) return;
|
||||
setStatus('Streckendetails werden geladen …');
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const detail = payload.route || payload;
|
||||
state.currentRoute = detail;
|
||||
state.pois = poiArray(detail);
|
||||
fillRouteForm(detail);
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function newRoute() {
|
||||
state.currentRoute = null;
|
||||
state.currentPoi = null;
|
||||
state.pois = [];
|
||||
fillRouteForm({});
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
}
|
||||
|
||||
function formDataFromRoute(values) {
|
||||
const data = new FormData();
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
data.append('name', values.name);
|
||||
data.append('description', values.description);
|
||||
data.append('activated_at', '');
|
||||
if (file) data.append('file', file);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveRoute() {
|
||||
const values = routeFromForm();
|
||||
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
|
||||
const id = byId(ids.routeId).value;
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
const path = id ? `/routes/${encodeURIComponent(id)}` : '/routes';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const payload = file ? await sendForm(path, method, formDataFromRoute(values)) : await sendJson(path, method, values);
|
||||
const saved = payload.route || payload;
|
||||
state.currentRoute = Object.keys(saved).length ? saved : { ...values, id };
|
||||
setStatus('Strecke gespeichert. Neue Strecken bleiben bis zur manuellen Aktivierung unveröffentlicht.');
|
||||
await loadRoutes();
|
||||
if (routeId(state.currentRoute)) await loadRoute(state.currentRoute);
|
||||
}
|
||||
|
||||
function newPoi() {
|
||||
state.currentPoi = null;
|
||||
fillPoiForm({ radiusM: 25 });
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
function editPoi(poi) {
|
||||
state.currentPoi = poi;
|
||||
fillPoiForm(poi);
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const route = state.currentRoute;
|
||||
const currentRouteId = routeId(route);
|
||||
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
||||
|
||||
const currentPoiId = byId(ids.poiId).value;
|
||||
const path = currentPoiId
|
||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||
const method = currentPoiId ? 'PUT' : 'POST';
|
||||
const payload = await sendJson(path, method, values);
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Station gespeichert.');
|
||||
await loadRoute(route);
|
||||
}
|
||||
|
||||
async function uploadPoiMedia(currentRouteId, currentPoiId) {
|
||||
const audio = byId(ids.poiAudio)?.files?.[0];
|
||||
const images = Array.from(byId(ids.poiImages)?.files || []);
|
||||
if (audio) await uploadOne(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}/audio`, audio);
|
||||
for (const image of images) {
|
||||
await uploadOne(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}/images`, image);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOne(path, file) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return sendForm(path, 'POST', data);
|
||||
}
|
||||
|
||||
function swapPois(index, offset) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= state.pois.length) return;
|
||||
const reordered = [...state.pois];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
state.pois = reordered;
|
||||
renderPoiList();
|
||||
guarded(savePoiOrder);
|
||||
}
|
||||
|
||||
async function savePoiOrder() {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) return;
|
||||
const jobs = state.pois.map((poi, index) => {
|
||||
const currentPoiId = poiId(poi);
|
||||
if (!currentPoiId) return null;
|
||||
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||||
return sendJson(path, 'PUT', { ...poi, position: index, sortOrder: index });
|
||||
}).filter(Boolean);
|
||||
await Promise.all(jobs);
|
||||
setStatus('Reihenfolge gespeichert.');
|
||||
}
|
||||
|
||||
function routeForItem(target) {
|
||||
const item = target.closest('[data-route-id]');
|
||||
return state.routes.find(route => routeId(route) === item?.dataset.routeId);
|
||||
}
|
||||
|
||||
function poiForItem(target) {
|
||||
const item = target.closest('[data-poi-id]');
|
||||
return state.pois.find(poi => poiId(poi) === item?.dataset.poiId);
|
||||
}
|
||||
|
||||
function poiIndexForItem(target) {
|
||||
const item = target.closest('[data-poi-id]');
|
||||
return state.pois.findIndex(poi => poiId(poi) === item?.dataset.poiId);
|
||||
}
|
||||
|
||||
async function routeListAction(event) {
|
||||
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
|
||||
if (action !== 'edit-route') return;
|
||||
await guarded(() => loadRoute(routeForItem(event.target)));
|
||||
}
|
||||
|
||||
function poiListAction(event) {
|
||||
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
|
||||
if (action === 'edit-poi') editPoi(poiForItem(event.target));
|
||||
if (action === 'move-poi-up') swapPois(poiIndexForItem(event.target), -1);
|
||||
if (action === 'move-poi-down') swapPois(poiIndexForItem(event.target), 1);
|
||||
}
|
||||
|
||||
async function guarded(action) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
activate(byId(ids.button), () => guarded(openEditor));
|
||||
activate(byId(ids.close), closeEditor);
|
||||
activate(byId(ids.newRoute), newRoute);
|
||||
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
|
||||
activate(byId(ids.addPoi), newPoi);
|
||||
activate(byId(ids.poiCancel), showRouteView);
|
||||
activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
|
||||
activate(byId(ids.poiList), poiListAction);
|
||||
submit(byId(ids.routeForm), () => guarded(saveRoute));
|
||||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||||
}
|
||||
|
||||
function init() {
|
||||
bind();
|
||||
ns.EditorUi = Object.freeze({
|
||||
close: closeEditor,
|
||||
open: openEditor,
|
||||
refresh: loadRoutes
|
||||
});
|
||||
}
|
||||
|
||||
ready(init);
|
||||
}(globalThis.Wegwichtel));
|
||||
Reference in New Issue
Block a user