diff --git a/public/css/editor.css b/public/css/editor.css
index fe347f5..13b82f4 100644
--- a/public/css/editor.css
+++ b/public/css/editor.css
@@ -152,6 +152,7 @@
.editor-button,
.editor-primary-button,
.editor-secondary-button,
+.editor-danger-button,
.editor-icon-button {
border-radius: .75rem;
font: inherit;
@@ -216,6 +217,122 @@
width: 2rem;
}
+.editor-media-preview {
+ display: grid;
+ gap: .65rem;
+ grid-template-columns: repeat(auto-fill, minmax(6.25rem, 1fr));
+}
+
+.editor-media-preview:empty {
+ display: none;
+}
+
+.editor-media-preview-card {
+ background: #fff;
+ border: 1px solid rgba(49, 93, 58, .22);
+ border-radius: .8rem;
+ min-width: 0;
+ padding: .35rem;
+ position: relative;
+}
+
+.editor-media-preview-card[data-selected="true"] {
+ border-color: #315d3a;
+ box-shadow: 0 0 0 .12rem rgba(49, 93, 58, .18);
+}
+
+.editor-media-preview-select {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #233326;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ font: inherit;
+ gap: .35rem;
+ min-height: 5.2rem;
+ padding: .25rem;
+ width: 100%;
+}
+
+.editor-media-preview-select small {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.editor-media-preview-image {
+ aspect-ratio: 1 / 1;
+ border-radius: .55rem;
+ display: block;
+ object-fit: cover;
+ width: 100%;
+}
+
+.editor-media-preview-audio {
+ align-items: center;
+ background: #eef5ef;
+ border-radius: .55rem;
+ display: flex;
+ font-size: 2rem;
+ justify-content: center;
+ min-height: 4.1rem;
+ width: 100%;
+}
+
+.editor-media-remove {
+ align-items: center;
+ background: #b42318;
+ border: 2px solid #fff;
+ border-radius: 999px;
+ color: #fff;
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 1.45rem;
+ font-weight: 800;
+ height: 2.25rem;
+ justify-content: center;
+ line-height: 1;
+ position: absolute;
+ right: .15rem;
+ top: .15rem;
+ width: 2.25rem;
+}
+
+.editor-media-remove[hidden] {
+ display: none;
+}
+
+.editor-form-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .65rem;
+}
+
+.editor-form-actions > button {
+ flex: 1 1 11rem;
+}
+
+.editor-danger-button {
+ align-items: center;
+ background: #b42318;
+ border: 1px solid #b42318;
+ color: #fff;
+ display: inline-flex;
+ font-weight: 700;
+ gap: .55rem;
+ justify-content: center;
+}
+
+.editor-danger-icon {
+ color: #fff;
+ font-size: 1.45rem;
+ font-weight: 900;
+ line-height: 1;
+}
+
.editor-round-button {
align-items: center;
background: #315d3a;
@@ -236,6 +353,9 @@
.editor-round-button:focus-visible,
.editor-primary-button:focus-visible,
.editor-secondary-button:focus-visible,
+.editor-danger-button:focus-visible,
+.editor-media-preview-select:focus-visible,
+.editor-media-remove:focus-visible,
.editor-icon-button:focus-visible {
outline: .2rem solid #f2ca6b;
outline-offset: .15rem;
@@ -383,12 +503,6 @@
resize: vertical;
}
-.editor-coordinate-grid {
- display: grid;
- gap: .55rem;
- grid-template-columns: minmax(0, 1fr);
-}
-
.editor-secondary-button,
.editor-icon-button {
background: #fff;
@@ -446,12 +560,6 @@
}
}
-@media (min-width: 42rem) {
- .editor-coordinate-grid {
- grid-template-columns: max-content minmax(0, 1fr);
- }
-}
-
@media (max-width: 34rem) {
.editor-media-actions {
grid-template-columns: 1fr;
diff --git a/public/index.html b/public/index.html
index 63221d6..58551d4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -391,15 +391,9 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js
index 1f9ec1f..c6d4cfd 100644
--- a/public/js/editor-ui.js
+++ b/public/js/editor-ui.js
@@ -13,6 +13,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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',
@@ -23,6 +24,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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',
@@ -45,16 +47,24 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const classes = Object.freeze({
button: 'editor-button',
- coordinateGrid: 'editor-coordinate-grid',
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',
iconButton: 'editor-icon-button',
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',
@@ -75,8 +85,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
currentPoi: null,
currentRoute: null,
currentSource: null,
+ pendingAudioFile: null,
+ pendingImages: [],
pois: [],
- routes: []
+ routes: [],
+ selectedMediaKey: null
};
function ready(callback) {
@@ -261,6 +274,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return handleResponse(response);
}
+ async function sendDelete(path) {
+ const response = await fetch(apiUrl(path), {
+ credentials: 'same-origin',
+ headers: { 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;
@@ -455,22 +477,131 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
}
+ function resetPendingMedia() {
+ state.pendingAudioFile = null;
+ 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?.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', `${label}: Entfernen-Schaltfläche ${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 audio = document.createElement('span');
+ audio.className = classes.mediaPreviewAudio;
+ audio.setAttribute('aria-hidden', 'true');
+ audio.textContent = '♪';
+ select.appendChild(audio);
+ }
+ 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 (existingAudioUrl(poi)) {
+ preview.appendChild(mediaPreviewCard({
+ key: 'existing-audio',
+ kind: 'audio',
+ label: poi?.audio?.name || 'Audioaufnahme',
+ source: 'existing'
+ }));
+ }
+
+ if (state.pendingAudioFile) {
+ preview.appendChild(mediaPreviewCard({
+ key: 'pending-audio',
+ kind: 'audio',
+ label: state.pendingAudioFile.name || 'Neue Audioaufnahme',
+ 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 || `Bild ${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 || `Neues Bild ${index + 1}`,
+ mediaIndex: index,
+ previewUrl: pending.preview?.dataUrl || null,
+ source: 'pending'
+ }));
+ });
+ }
+
function updatePoiMediaStatus(poi = state.currentPoi) {
const status = byId(ids.poiMediaStatus);
if (!status) return;
- const selectedAudio = byId(ids.poiAudio)?.files?.[0];
- const selectedImages = Array.from(byId(ids.poiImages)?.files || []);
- const hasExistingAudio = Boolean(poi?.audio || poi?.audioUrl || poi?.audio_path);
+ const existingAudio = 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 (selectedAudio) parts.push(`Audio ausgewählt: ${selectedAudio.name || 'Audiodatei'}`);
- else if (hasExistingAudio) parts.push('Audio vorhanden');
+ if (audioCount) parts.push(`${audioCount} Audio${audioCount === 1 ? 'aufnahme' : 'aufnahmen'}`);
+ if (imageCount) parts.push(`${imageCount} Bild${imageCount === 1 ? '' : 'er'}`);
- if (selectedImages.length) parts.push(`${selectedImages.length} Bild${selectedImages.length === 1 ? '' : 'er'} ausgewählt`);
- else if (existingImages) parts.push(`${existingImages} Bild${existingImages === 1 ? '' : 'er'} vorhanden`);
-
- status.textContent = parts.length ? parts.join(' · ') : 'Noch keine neuen Medien ausgewählt.';
+ status.textContent = parts.length
+ ? `${parts.join(' · ')}. Vorschau antippen, um die Entfernen-Schaltfläche einzublenden.`
+ : 'Noch keine Medien vorhanden.';
}
function openFilePicker(inputId) {
@@ -479,17 +610,41 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function fillPoiForm(poi) {
+ resetPendingMedia();
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?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
- byId(ids.poiAudio).value = '';
- byId(ids.poiImages).value = '';
+ setHidden(byId(ids.poiDelete), !poiId(poi));
+ renderPoiMediaPreview(poi);
updatePoiMediaStatus(poi);
}
+ function handleAudioSelection() {
+ 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,
+ key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
+ preview: await ns.LocalRoutes.fileToDataUrl(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;
@@ -602,12 +757,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function newPoi() {
- state.currentPoi = null;
const position = currentPosition();
+ const lat = Number(position?.lat);
+ const lon = Number(position?.lon);
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
+ setStatus('Für einen neuen POI muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
+ return;
+ }
+
+ const accuracy = Number(position?.accuracy);
+ state.currentPoi = null;
fillPoiForm({
- lat: position?.lat ?? '',
- lon: position?.lon ?? '',
- triggerRadiusM: 25
+ lat,
+ lon,
+ triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
});
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
showPoiView();
@@ -621,15 +784,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
- const audioFile = byId(ids.poiAudio)?.files?.[0];
- const imageFiles = Array.from(byId(ids.poiImages)?.files || []);
- const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null;
+ const audio = state.pendingAudioFile
+ ? await ns.LocalRoutes.fileToDataUrl(state.pendingAudioFile)
+ : existingPoi?.audio || null;
const images = [...(existingPoi?.images || [])];
- for (const imageFile of imageFiles) {
- const image = await ns.LocalRoutes.fileToDataUrl(imageFile);
+ for (const pending of state.pendingImages) {
+ const image = pending.preview || await ns.LocalRoutes.fileToDataUrl(pending.file);
images.push({
...image,
- id: `${poiIdentifier}-image-${Date.now()}-${images.length}`,
+ id: `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`,
caption: ''
});
}
@@ -639,6 +802,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function persistLocalCurrentRoute() {
const id = routeId(state.currentRoute);
if (!id) throw new Error('Speichere zuerst die Strecke.');
+ const currentPoiId = poiId(state.currentPoi);
const saved = ns.LocalRoutes.save({
id,
gpx: state.currentRoute.gpx,
@@ -647,6 +811,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
});
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;
return saved;
}
@@ -673,19 +840,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
}
- async function uploadOne(path, fieldName, file) {
+ async function uploadOne(path, fieldName, file, method = 'POST') {
const data = new FormData();
data.append(fieldName, file);
- return sendForm(path, 'POST', data);
+ return sendForm(path, method, data);
}
async function uploadPoiMedia(currentRouteId, currentPoiId) {
- const audio = byId(ids.poiAudio)?.files?.[0];
- const images = Array.from(byId(ids.poiImages)?.files || []);
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
- if (audio) await uploadOne(`${basePath}/audio`, 'audio', audio);
- for (const image of images) {
- await uploadOne(`${basePath}/pictures`, 'picture', image);
+ if (state.pendingAudioFile) {
+ const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST';
+ await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method);
+ }
+ for (const pending of state.pendingImages) {
+ await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
}
}
@@ -712,6 +880,116 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
else await saveServerPoi(values);
}
+ 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;
+ } else {
+ const index = Number.parseInt(mediaIndex, 10);
+ if (Number.isInteger(index)) state.pendingImages.splice(index, 1);
+ }
+ state.selectedMediaKey = null;
+ renderPoiMediaPreview();
+ updatePoiMediaStatus();
+ }
+
+ async function removeExistingLocalMedia(kind, mediaIndex) {
+ if (!state.currentPoi) return;
+ const updated = { ...state.currentPoi };
+ if (kind === 'audio') {
+ updated.audio = null;
+ } else {
+ const index = Number.parseInt(mediaIndex, 10);
+ updated.images = [...(state.currentPoi.images || [])];
+ if (Number.isInteger(index)) updated.images.splice(index, 1);
+ }
+ updateCurrentPoi(updated);
+ await persistLocalCurrentRoute();
+ state.selectedMediaKey = null;
+ renderPoiMediaPreview();
+ updatePoiMediaStatus();
+ setStatus(kind === 'audio' ? 'Audioaufnahme entfernt.' : 'Bild entfernt.');
+ }
+
+ async function removeExistingServerMedia(kind, mediaId) {
+ const currentRouteId = routeId(state.currentRoute);
+ const currentPoiId = poiId(state.currentPoi);
+ if (!currentRouteId || !currentPoiId) throw new Error('Der POI ist noch nicht gespeichert.');
+ const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
+ const updated = { ...state.currentPoi };
+
+ if (kind === 'audio') {
+ await sendDelete(`${basePath}/audio`);
+ updated.audio = null;
+ updated.audioUrl = null;
+ updated.audio_path = null;
+ } else {
+ if (!mediaId) throw new Error('Das Bild besitzt keine Server-ID und kann nicht entfernt werden.');
+ 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();
+ setStatus(kind === 'audio' ? 'Audioaufnahme entfernt.' : 'Bild entfernt.');
+ }
+
+ 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') {
+ state.pois = state.pois
+ .filter(poi => poiId(poi) !== currentPoiId)
+ .map((poi, sequence) => ({ ...poi, sequence }));
+ state.currentPoi = null;
+ await persistLocalCurrentRoute();
+ renderPoiList();
+ showRouteView();
+ setStatus('POI entfernt und lokale Strecke aktualisiert.');
+ return;
+ }
+
+ await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
+ await loadRoute({ id: currentRouteId, _source: 'server' });
+ setStatus('POI entfernt.');
+ }
+
function reorderPois(index, offset) {
const target = index + offset;
if (target < 0 || target >= state.pois.length) return false;
@@ -797,8 +1075,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
activate(byId(ids.addPoi), newPoi);
activate(byId(ids.poiAudioAdd), () => openFilePicker(ids.poiAudio));
activate(byId(ids.poiImagesAdd), () => openFilePicker(ids.poiImages));
- byId(ids.poiAudio)?.addEventListener('change', () => updatePoiMediaStatus());
- byId(ids.poiImages)?.addEventListener('change', () => updatePoiMediaStatus());
+ 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), () => guarded(deleteCurrentPoi));
activate(byId(ids.poiCancel), showRouteView);
activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
activate(byId(ids.poiList), poiListAction);
diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js
index 186d702..cbdfdc9 100644
--- a/test/editor-ui.test.js
+++ b/test/editor-ui.test.js
@@ -28,6 +28,12 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(html, /id="editor-poi-images-add"[^>]+type="button"/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"[^>]+hidden/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple[^>]+hidden/);
+ assert.match(html, /id="editor-poi-lat"[^>]+type="hidden"/);
+ assert.match(html, /id="editor-poi-lon"[^>]+type="hidden"/);
+ assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
+ assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
+ assert.match(html, /id="editor-poi-media-preview"/);
+ assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i);
@@ -35,6 +41,9 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(css, /\.editor-poi-list/);
assert.match(css, /\.editor-route-group/);
assert.match(css, /\.editor-media-button/);
+ assert.match(css, /\.editor-media-preview/);
+ assert.match(css, /\.editor-media-remove/);
+ assert.match(css, /\.editor-danger-button/);
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/);
@@ -51,6 +60,8 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(ui, /\/pictures/);
assert.match(ui, /'picture'/);
assert.match(ui, /'audio'/);
+ assert.match(ui, /method:\s*'DELETE'/);
+ assert.match(ui, /position\?\.accuracy/);
});
class FakeElement extends EventTarget {
@@ -167,6 +178,7 @@ function buildEditorContext() {
'editor-poi-audio',
'editor-poi-audio-add',
'editor-poi-cancel',
+ 'editor-poi-delete',
'editor-poi-description',
'editor-poi-form',
'editor-poi-form-title',
@@ -177,6 +189,7 @@ function buildEditorContext() {
'editor-poi-lat',
'editor-poi-list',
'editor-poi-lon',
+ 'editor-poi-media-preview',
'editor-poi-media-status',
'editor-poi-radius',
'editor-poi-title',
@@ -228,7 +241,10 @@ function buildEditorContext() {
}
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
- if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true });
+ if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ ok: true });
+ if (/^\/api\/routes\/1\/pois\/\d+\/audio$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
+ if (/^\/api\/routes\/1\/pois\/\d+\/pictures\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
+ if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
}
@@ -270,7 +286,7 @@ function buildEditorContext() {
globalThis: null,
structuredClone,
Wegwichtel: {
- App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) },
+ App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
Config: { apiBase: '/api/' },
LocalRoutes
}
@@ -311,27 +327,63 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-audio').clickCount, 1);
assert.equal(elements.get('editor-poi-images').clickCount, 1);
+ assert.equal(elements.get('editor-poi-lat').value, '50.123456789');
+ assert.equal(elements.get('editor-poi-lon').value, '10.987654321');
+ assert.equal(elements.get('editor-poi-radius').value, '4.321');
elements.get('editor-poi-title').value = 'Aussicht';
elements.get('editor-poi-description').value = 'Text zur Station';
- elements.get('editor-poi-lat').value = '50.51';
- elements.get('editor-poi-lon').value = '10.52';
- elements.get('editor-poi-radius').value = '35';
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
+ elements.get('editor-poi-audio').dispatchEvent(new Event('change'));
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
+ elements.get('editor-poi-images').dispatchEvent(new Event('change'));
+ await tick();
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
- const local = packages.get('local-new');
+ let local = packages.get('local-new');
assert.equal(local.pois.length, 1);
assert.equal(local.pois[0].title, 'Aussicht');
- assert.equal(local.pois[0].lat, 50.51);
- assert.equal(local.pois[0].lon, 10.52);
- assert.equal(local.pois[0].triggerRadiusM, 35);
+ assert.equal(local.pois[0].lat, 50.123456789);
+ assert.equal(local.pois[0].lon, 10.987654321);
+ assert.equal(local.pois[0].triggerRadiusM, 4.321);
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
assert.equal(local.pois[0].images.length, 1);
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
+ const poiList = elements.get('editor-poi-list');
+ const poiItem = poiList.children.find(child => child.dataset.poiId);
+ const poiEditButton = poiItem.children[1].children[2];
+ poiList.dispatchEvent(pointerEvent(poiEditButton));
+ const mediaPreview = elements.get('editor-poi-media-preview');
+ assert.equal(mediaPreview.children.length, 2);
+
+ let imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
+ mediaPreview.dispatchEvent(pointerEvent(imageCard.children[0]));
+ imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
+ assert.equal(imageCard.children[1].hidden, false);
+ mediaPreview.dispatchEvent(pointerEvent(imageCard.children[1]));
+ await tick();
+ await tick();
+ local = packages.get('local-new');
+ assert.equal(local.pois[0].images.length, 0);
+
+ let audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
+ elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[0]));
+ audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
+ elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[1]));
+ await tick();
+ await tick();
+ local = packages.get('local-new');
+ assert.equal(local.pois[0].audio, null);
+
+ assert.equal(elements.get('editor-poi-delete').hidden, false);
+ elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
+ await tick();
+ await tick();
+ local = packages.get('local-new');
+ assert.equal(local.pois.length, 0);
+
context.Wegwichtel.EditorUi.close();
assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');