From 6b3f228ad819934d147c8cae9909292e363ebf35 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Sun, 27 Sep 2026 17:38:24 +0200 Subject: [PATCH] added "add media" buttons to prevent media picker opening automatically and added status captions --- public/css/editor.css | 49 ++++++++++++++++++++++++++++++++++++++++++ public/index.html | 24 +++++++++++++++++---- public/js/editor-ui.js | 47 ++++++++++++++++++++++++++++++++++------ test/editor-ui.test.js | 28 +++++++++++++++++++++--- 4 files changed, 135 insertions(+), 13 deletions(-) diff --git a/public/css/editor.css b/public/css/editor.css index ccbc661..fe347f5 100644 --- a/public/css/editor.css +++ b/public/css/editor.css @@ -173,6 +173,49 @@ font-weight: 700; } +.editor-media-actions { + display: grid; + gap: .75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.editor-media-button { + align-items: center; + background: #fff; + border: 1px solid rgba(49, 93, 58, .35); + border-radius: .85rem; + color: #233326; + cursor: pointer; + display: inline-flex; + font: inherit; + font-weight: 700; + gap: .65rem; + justify-content: center; + min-height: 3.5rem; + padding: .65rem .8rem; +} + +.editor-media-button:hover { + background: #eef5ef; + border-color: #315d3a; +} + +.editor-media-button:focus-visible { + outline: .2rem solid #f2ca6b; + outline-offset: .15rem; +} + +.editor-media-icon { + fill: none; + flex: 0 0 2rem; + height: 2rem; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + width: 2rem; +} + .editor-round-button { align-items: center; background: #315d3a; @@ -408,3 +451,9 @@ 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 fa7a0b0..63221d6 100644 --- a/public/index.html +++ b/public/index.html @@ -401,10 +401,26 @@ - - - - +
+ + +
+ + +

diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index faf4907..1f9ec1f 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -11,6 +11,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; dialogTitle: 'editor-dialog-title', newRoute: 'editor-new-route', poiAudio: 'editor-poi-audio', + poiAudioAdd: 'editor-poi-audio-add', poiCancel: 'editor-poi-cancel', poiDescription: 'editor-poi-description', poiForm: 'editor-poi-form', @@ -18,9 +19,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; 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', + poiMediaStatus: 'editor-poi-media-status', poiRadius: 'editor-poi-radius', poiSave: 'editor-poi-save', poiTitle: 'editor-poi-title', @@ -49,6 +52,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; form: 'editor-form', iconButton: 'editor-icon-button', iconRow: 'editor-icon-row', + mediaActions: 'editor-media-actions', + mediaButton: 'editor-media-button', + mediaIcon: 'editor-media-icon', muted: 'editor-muted', poiItem: 'editor-poi-item', poiList: 'editor-poi-list', @@ -449,6 +455,29 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; } } + 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 existingImages = Array.isArray(poi?.images) ? poi.images.length : 0; + const parts = []; + + if (selectedAudio) parts.push(`Audio ausgewählt: ${selectedAudio.name || 'Audiodatei'}`); + else if (hasExistingAudio) parts.push('Audio vorhanden'); + + 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.'; + } + + function openFilePicker(inputId) { + const input = byId(inputId); + if (input && typeof input.click === 'function') input.click(); + } + function fillPoiForm(poi) { byId(ids.poiId).value = poiId(poi); byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannte Station' ? '' : poiTitle(poi); @@ -458,6 +487,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; byId(ids.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25); byId(ids.poiAudio).value = ''; byId(ids.poiImages).value = ''; + updatePoiMediaStatus(poi); } async function loadRoute(route) { @@ -540,9 +570,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' }; state.currentSource = 'local'; state.pois = saved.pois; - setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.'); await loadRoutes(); await loadRoute({ id: saved.id, _source: 'local' }); + setStatus('Strecke erfolgreich lokal gespeichert. Es wurden keine Daten an den Server übertragen.'); } async function saveServerRoute(values) { @@ -555,9 +585,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; : await sendJson(path, 'PUT', values); const saved = payload.route || payload; state.currentRoute = { ...saved, _source: 'server' }; - setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.'); await loadRoutes(); await loadRoute({ id, _source: 'server' }); + setStatus('Strecke erfolgreich gespeichert. activated_at wurde nicht verändert.'); } async function saveRoute() { @@ -643,18 +673,19 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; await loadRoute({ id: routeId(state.currentRoute), _source: 'local' }); } - async function uploadOne(path, file) { + async function uploadOne(path, fieldName, file) { const data = new FormData(); - data.append('file', file); + data.append(fieldName, file); return sendForm(path, 'POST', data); } 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); + const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`; + if (audio) await uploadOne(`${basePath}/audio`, 'audio', audio); for (const image of images) { - await uploadOne(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}/images`, image); + await uploadOne(`${basePath}/pictures`, 'picture', image); } } @@ -764,6 +795,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; activate(byId(ids.newRoute), newRoute); activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView)); 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()); 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 9427572..186d702 100644 --- a/test/editor-ui.test.js +++ b/test/editor-ui.test.js @@ -24,13 +24,17 @@ test('editor markup and source describe local draft creation instead of immediat assert.match(html, /id="editor-route-form"/); assert.match(html, /id="editor-poi-form"/); assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/); - assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/); - assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/); + assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/); + 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.doesNotMatch(html, /accept="video\//); assert.match(html, /ausschließlich in diesem Browser/i); assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-poi-list/); assert.match(css, /\.editor-route-group/); + assert.match(css, /\.editor-media-button/); assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/); assert.match(ui, /ns\.LocalRoutes\.save\(/); @@ -44,6 +48,9 @@ test('editor markup and source describe local draft creation instead of immediat assert.doesNotMatch(ui, /activated_at['"],\s*['"]/); assert.doesNotMatch(ui, /activatedAt:\s*null/); assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/); + assert.match(ui, /\/pictures/); + assert.match(ui, /'picture'/); + assert.match(ui, /'audio'/); }); class FakeElement extends EventTarget { @@ -64,6 +71,7 @@ class FakeElement extends EventTarget { this.title = ''; this.type = ''; this._textContent = ''; + this.clickCount = 0; } get textContent() { @@ -96,6 +104,10 @@ class FakeElement extends EventTarget { if (name === 'open') this.open = false; } + click() { + this.clickCount += 1; + } + showModal() { this.open = true; } @@ -153,6 +165,7 @@ function buildEditorContext() { 'editor-dialog', 'editor-new-route', 'editor-poi-audio', + 'editor-poi-audio-add', 'editor-poi-cancel', 'editor-poi-description', 'editor-poi-form', @@ -160,9 +173,11 @@ function buildEditorContext() { 'editor-poi-form-view', 'editor-poi-id', 'editor-poi-images', + 'editor-poi-images-add', 'editor-poi-lat', 'editor-poi-list', 'editor-poi-lon', + 'editor-poi-media-status', 'editor-poi-radius', 'editor-poi-title', 'editor-route-activated', @@ -213,7 +228,7 @@ 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|images)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }); + if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }); return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` }; } @@ -287,8 +302,15 @@ test('public editor workflow saves new route and GPX POI locally without POSTing assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke'); assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/); + assert.match(elements.get('editor-status').textContent, /erfolgreich lokal gespeichert/i); elements.get('editor-add-poi').dispatchEvent(pointerEvent()); + assert.equal(elements.get('editor-poi-audio').clickCount, 0); + assert.equal(elements.get('editor-poi-images').clickCount, 0); + elements.get('editor-poi-audio-add').dispatchEvent(pointerEvent()); + 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); elements.get('editor-poi-title').value = 'Aussicht'; elements.get('editor-poi-description').value = 'Text zur Station'; elements.get('editor-poi-lat').value = '50.51';