added "add media" buttons to prevent media picker opening automatically and added status captions
Sonarqube Scanner / Build and analyze (push) Successful in 1m2s

This commit is contained in:
Florian Zumpe
2026-09-27 17:38:24 +02:00
parent 18bb4682a0
commit 6b3f228ad8
4 changed files with 135 additions and 13 deletions
+49
View File
@@ -173,6 +173,49 @@
font-weight: 700; 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 { .editor-round-button {
align-items: center; align-items: center;
background: #315d3a; background: #315d3a;
@@ -408,3 +451,9 @@
grid-template-columns: max-content minmax(0, 1fr); grid-template-columns: max-content minmax(0, 1fr);
} }
} }
@media (max-width: 34rem) {
.editor-media-actions {
grid-template-columns: 1fr;
}
}
+20 -4
View File
@@ -401,10 +401,26 @@
<input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25"> <input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25">
</div> </div>
<label for="editor-poi-audio">Audiodatei verknüpfen</label> <div class="editor-media-actions" aria-label="Medien zur Station hinzufügen">
<input id="editor-poi-audio" type="file" accept="audio/*"> <button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen">
<label for="editor-poi-images">Bilder verknüpfen</label> <svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<input id="editor-poi-images" type="file" accept="image/*" multiple> <path d="M18 5v15.2a5 5 0 1 1-2-4V9l10-2v9.2a5 5 0 1 1-2-4V4.6z"/>
<path d="M6 4v6M3 7h6"/>
</svg>
<span>Audio hinzufügen</span>
</button>
<button id="editor-poi-images-add" class="editor-media-button" type="button" title="Bild hinzufügen">
<svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M6 10h4l2-3h8l2 3h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V12a2 2 0 0 1 2-2z"/>
<circle cx="16" cy="18" r="5"/>
<path d="M6 3v6M3 6h6"/>
</svg>
<span>Bild hinzufügen</span>
</button>
</div>
<input id="editor-poi-audio" type="file" accept="audio/*" hidden>
<input id="editor-poi-images" type="file" accept="image/*" multiple hidden>
<p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p>
<button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button> <button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button>
</form> </form>
+41 -6
View File
@@ -11,6 +11,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
dialogTitle: 'editor-dialog-title', dialogTitle: 'editor-dialog-title',
newRoute: 'editor-new-route', newRoute: 'editor-new-route',
poiAudio: 'editor-poi-audio', poiAudio: 'editor-poi-audio',
poiAudioAdd: 'editor-poi-audio-add',
poiCancel: 'editor-poi-cancel', poiCancel: 'editor-poi-cancel',
poiDescription: 'editor-poi-description', poiDescription: 'editor-poi-description',
poiForm: 'editor-poi-form', poiForm: 'editor-poi-form',
@@ -18,9 +19,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
poiFormView: 'editor-poi-form-view', poiFormView: 'editor-poi-form-view',
poiId: 'editor-poi-id', poiId: 'editor-poi-id',
poiImages: 'editor-poi-images', poiImages: 'editor-poi-images',
poiImagesAdd: 'editor-poi-images-add',
poiLat: 'editor-poi-lat', poiLat: 'editor-poi-lat',
poiList: 'editor-poi-list', poiList: 'editor-poi-list',
poiLon: 'editor-poi-lon', poiLon: 'editor-poi-lon',
poiMediaStatus: 'editor-poi-media-status',
poiRadius: 'editor-poi-radius', poiRadius: 'editor-poi-radius',
poiSave: 'editor-poi-save', poiSave: 'editor-poi-save',
poiTitle: 'editor-poi-title', poiTitle: 'editor-poi-title',
@@ -49,6 +52,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
form: 'editor-form', form: 'editor-form',
iconButton: 'editor-icon-button', iconButton: 'editor-icon-button',
iconRow: 'editor-icon-row', iconRow: 'editor-icon-row',
mediaActions: 'editor-media-actions',
mediaButton: 'editor-media-button',
mediaIcon: 'editor-media-icon',
muted: 'editor-muted', muted: 'editor-muted',
poiItem: 'editor-poi-item', poiItem: 'editor-poi-item',
poiList: 'editor-poi-list', 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) { function fillPoiForm(poi) {
byId(ids.poiId).value = poiId(poi); byId(ids.poiId).value = poiId(poi);
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannte Station' ? '' : poiTitle(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.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
byId(ids.poiAudio).value = ''; byId(ids.poiAudio).value = '';
byId(ids.poiImages).value = ''; byId(ids.poiImages).value = '';
updatePoiMediaStatus(poi);
} }
async function loadRoute(route) { 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.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
state.currentSource = 'local'; state.currentSource = 'local';
state.pois = saved.pois; state.pois = saved.pois;
setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.');
await loadRoutes(); await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' }); await loadRoute({ id: saved.id, _source: 'local' });
setStatus('Strecke erfolgreich lokal gespeichert. Es wurden keine Daten an den Server übertragen.');
} }
async function saveServerRoute(values) { async function saveServerRoute(values) {
@@ -555,9 +585,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
: await sendJson(path, 'PUT', values); : await sendJson(path, 'PUT', values);
const saved = payload.route || payload; const saved = payload.route || payload;
state.currentRoute = { ...saved, _source: 'server' }; state.currentRoute = { ...saved, _source: 'server' };
setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.');
await loadRoutes(); await loadRoutes();
await loadRoute({ id, _source: 'server' }); await loadRoute({ id, _source: 'server' });
setStatus('Strecke erfolgreich gespeichert. activated_at wurde nicht verändert.');
} }
async function saveRoute() { async function saveRoute() {
@@ -643,18 +673,19 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' }); await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
} }
async function uploadOne(path, file) { async function uploadOne(path, fieldName, file) {
const data = new FormData(); const data = new FormData();
data.append('file', file); data.append(fieldName, file);
return sendForm(path, 'POST', data); return sendForm(path, 'POST', data);
} }
async function uploadPoiMedia(currentRouteId, currentPoiId) { async function uploadPoiMedia(currentRouteId, currentPoiId) {
const audio = byId(ids.poiAudio)?.files?.[0]; const audio = byId(ids.poiAudio)?.files?.[0];
const images = Array.from(byId(ids.poiImages)?.files || []); 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) { 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.newRoute), newRoute);
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView)); activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
activate(byId(ids.addPoi), newPoi); 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.poiCancel), showRouteView);
activate(byId(ids.routeList), event => guarded(() => routeListAction(event))); activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
activate(byId(ids.poiList), poiListAction); activate(byId(ids.poiList), poiListAction);
+25 -3
View File
@@ -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-route-form"/);
assert.match(html, /id="editor-poi-form"/); assert.match(html, /id="editor-poi-form"/);
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/); assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/); assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/); 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(html, /ausschließlich in diesem Browser/i);
assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-dialog/);
assert.match(css, /\.editor-poi-list/); assert.match(css, /\.editor-poi-list/);
assert.match(css, /\.editor-route-group/); assert.match(css, /\.editor-route-group/);
assert.match(css, /\.editor-media-button/);
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/); assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/); 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, /activated_at['"],\s*['"]/);
assert.doesNotMatch(ui, /activatedAt:\s*null/); assert.doesNotMatch(ui, /activatedAt:\s*null/);
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/); assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
assert.match(ui, /\/pictures/);
assert.match(ui, /'picture'/);
assert.match(ui, /'audio'/);
}); });
class FakeElement extends EventTarget { class FakeElement extends EventTarget {
@@ -64,6 +71,7 @@ class FakeElement extends EventTarget {
this.title = ''; this.title = '';
this.type = ''; this.type = '';
this._textContent = ''; this._textContent = '';
this.clickCount = 0;
} }
get textContent() { get textContent() {
@@ -96,6 +104,10 @@ class FakeElement extends EventTarget {
if (name === 'open') this.open = false; if (name === 'open') this.open = false;
} }
click() {
this.clickCount += 1;
}
showModal() { showModal() {
this.open = true; this.open = true;
} }
@@ -153,6 +165,7 @@ function buildEditorContext() {
'editor-dialog', 'editor-dialog',
'editor-new-route', 'editor-new-route',
'editor-poi-audio', 'editor-poi-audio',
'editor-poi-audio-add',
'editor-poi-cancel', 'editor-poi-cancel',
'editor-poi-description', 'editor-poi-description',
'editor-poi-form', 'editor-poi-form',
@@ -160,9 +173,11 @@ function buildEditorContext() {
'editor-poi-form-view', 'editor-poi-form-view',
'editor-poi-id', 'editor-poi-id',
'editor-poi-images', 'editor-poi-images',
'editor-poi-images-add',
'editor-poi-lat', 'editor-poi-lat',
'editor-poi-list', 'editor-poi-list',
'editor-poi-lon', 'editor-poi-lon',
'editor-poi-media-status',
'editor-poi-radius', 'editor-poi-radius',
'editor-poi-title', 'editor-poi-title',
'editor-route-activated', '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 (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+$/.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}` }; 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(saves.at(-1).route.name, 'Neue lokale Strecke');
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); 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-route-activated').textContent, /Lokaler Entwurf/);
assert.match(elements.get('editor-status').textContent, /erfolgreich lokal gespeichert/i);
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); 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-title').value = 'Aussicht';
elements.get('editor-poi-description').value = 'Text zur Station'; elements.get('editor-poi-description').value = 'Text zur Station';
elements.get('editor-poi-lat').value = '50.51'; elements.get('editor-poi-lat').value = '50.51';