From becd48b4878ac19a1be7c0c53a15ba907aba8b71 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Sun, 27 Sep 2026 17:59:09 +0200 Subject: [PATCH] add delete buttons to editor --- public/css/editor.css | 132 ++++++++++++++-- public/index.html | 21 +-- public/js/editor-ui.js | 344 +++++++++++++++++++++++++++++++++++++---- test/editor-ui.test.js | 70 +++++++-- 4 files changed, 504 insertions(+), 63 deletions(-) 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');