image handling update for route creation
Sonarqube Scanner / Build and analyze (push) Failing after 20s

This commit is contained in:
Florian Zumpe
2026-09-27 21:50:52 +02:00
parent 4b8105188f
commit 7f6d0425d5
16 changed files with 831 additions and 182 deletions
+174 -56
View File
@@ -36,6 +36,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
routeDetailView: 'editor-route-detail-view',
routeForm: 'editor-route-form',
routeGpx: 'editor-route-gpx',
routeGpxGroup: 'editor-route-gpx-group',
routeId: 'editor-route-id',
routeList: 'editor-route-list',
routeListTitle: 'editor-route-list-title',
@@ -88,6 +89,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
currentRoute: null,
currentSource: null,
pendingAudioFile: null,
pendingAudioHandle: null,
pendingImages: [],
pois: [],
routes: [],
@@ -128,7 +130,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function poiTitle(poi) {
return text(poi?.title || poi?.name || 'Unbenannter Trackpunkt');
return text(poi?.title || poi?.name || 'Unbenannter Streckenpunkt');
}
function apiBase() {
@@ -193,7 +195,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function showPoiView() {
setDialogTitle(state.currentPoi ? 'Trackpunkt bearbeiten' : 'Trackpunkt hinzufügen');
setDialogTitle(state.currentPoi ? 'Streckenpunkt bearbeiten' : 'Streckenpunkt hinzufügen');
setHidden(byId(ids.routeListView), true);
setHidden(byId(ids.routeDetailView), true);
setHidden(byId(ids.poiFormView), false);
@@ -357,7 +359,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const title = poiTitle(poi);
const label = document.createElement('div');
appendText(label, 'strong', title);
appendText(label, 'small', `Trackpunkt ${index + 1}`);
appendText(label, 'small', `Streckenpunkt ${index + 1}`);
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
@@ -397,7 +399,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!state.pois.length) {
const item = document.createElement('li');
item.className = classes.poiItem;
appendText(item, 'span', 'Noch keine Trackpunkte angelegt.');
appendText(item, 'span', 'Noch keine Streckenpunkte angelegt.');
list.appendChild(item);
return;
}
@@ -476,6 +478,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
byId(ids.routeDescription).value = text(route?.description);
byId(ids.routeGpx).value = '';
setHidden(byId(ids.routeGpxGroup), !routeId(route));
const uploadButton = byId(ids.routeUpload);
if (state.currentSource === 'local') {
@@ -495,8 +498,21 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
}
function objectUrlFor(file) {
if (!file || typeof globalThis.URL?.createObjectURL !== 'function') return null;
return globalThis.URL.createObjectURL(file);
}
function releasePendingPreview(pending) {
const url = pending?.previewUrl;
if (!url || !url.startsWith('blob:') || typeof globalThis.URL?.revokeObjectURL !== 'function') return;
globalThis.URL.revokeObjectURL(url);
}
function resetPendingMedia() {
state.pendingAudioFile = null;
state.pendingAudioHandle = null;
state.pendingImages.forEach(releasePendingPreview);
state.pendingImages = [];
state.selectedMediaKey = null;
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
@@ -504,7 +520,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function existingAudioUrl(poi) {
return poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
return poi?.audio?.url || poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
}
function imagePreviewUrl(image) {
@@ -531,11 +547,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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);
const mediaSymbol = document.createElement('span');
mediaSymbol.className = classes.mediaPreviewAudio;
mediaSymbol.setAttribute('aria-hidden', 'true');
mediaSymbol.textContent = kind === 'image' ? '▧' : '♪';
select.appendChild(mediaSymbol);
}
appendText(select, 'small', label);
@@ -562,7 +578,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!preview) return;
preview.textContent = '';
if (existingAudioUrl(poi)) {
if (poi?.audio || existingAudioUrl(poi)) {
preview.appendChild(mediaPreviewCard({
key: 'existing-audio',
kind: 'audio',
@@ -599,7 +615,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
kind: 'image',
label: pending.file?.name || `Neues Bild ${index + 1}`,
mediaIndex: index,
previewUrl: pending.preview?.dataUrl || null,
previewUrl: pending.previewUrl || null,
source: 'pending'
}));
});
@@ -608,7 +624,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function updatePoiMediaStatus(poi = state.currentPoi) {
const status = byId(ids.poiMediaStatus);
if (!status) return;
const existingAudio = existingAudioUrl(poi) ? 1 : 0;
const existingAudio = poi?.audio || 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;
@@ -627,10 +643,65 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (input && typeof input.click === 'function') input.click();
}
function pickerOptions(kind) {
if (kind === 'audio') {
return {
multiple: false,
types: [{ description: 'Audio', accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }]
};
}
return {
multiple: true,
types: [{ description: 'Bilder', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
};
}
async function pickWithFileSystemAccess(kind) {
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') return false;
let handles;
try {
handles = await globalThis.showOpenFilePicker(pickerOptions(kind));
} catch (error) {
if (error?.name === 'AbortError') return true;
return false;
}
if (kind === 'audio') {
const handle = handles?.[0];
if (!handle) return true;
state.pendingAudioHandle = handle;
state.pendingAudioFile = await handle.getFile();
} else {
for (const handle of handles || []) {
const file = await handle.getFile();
state.pendingImages.push({
file,
handle,
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
previewUrl: objectUrlFor(file)
});
}
}
state.selectedMediaKey = null;
renderPoiMediaPreview();
updatePoiMediaStatus();
return true;
}
function chooseMedia(kind, inputId) {
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') {
openFilePicker(inputId);
return null;
}
return pickWithFileSystemAccess(kind).then(handled => {
if (!handled) openFilePicker(inputId);
});
}
function fillPoiForm(poi) {
resetPendingMedia();
byId(ids.poiId).value = poiId(poi);
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Trackpunkt' ? '' : poiTitle(poi);
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Streckenpunkt' ? '' : 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);
@@ -641,6 +712,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function handleAudioSelection() {
state.pendingAudioHandle = null;
state.pendingAudioFile = byId(ids.poiAudio)?.files?.[0] || null;
state.selectedMediaKey = null;
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
@@ -653,8 +725,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
for (const file of files) {
state.pendingImages.push({
file,
handle: null,
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
preview: await ns.LocalRoutes.fileToDataUrl(file)
previewUrl: objectUrlFor(file)
});
}
state.selectedMediaKey = null;
@@ -670,7 +743,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.currentSource = routeSource(route);
if (state.currentSource === 'local') {
const routePackage = ns.LocalRoutes.get(id);
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
state.currentRoute = {
...routePackage.route,
@@ -793,12 +866,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
async function storedMediaBlob(media) {
if (!media?.dataUrl) return null;
const response = await fetch(media.dataUrl);
if (!response.ok || typeof response.blob !== 'function') {
throw new Error(`Die Mediendatei ${media.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true });
if (!blob) {
throw new Error(`Die Mediendatei ${media?.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
}
return response.blob();
return blob;
}
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
@@ -811,11 +883,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
if (poi.audio?.dataUrl) {
if (poi.audio) {
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
}
for (const [index, image] of (poi.images || []).entries()) {
if (!image?.dataUrl) continue;
if (!image) continue;
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
}
}
@@ -831,7 +903,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const orderedPois = [...(routePackage.pois || [])]
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
for (const [index, poi] of orderedPois.entries()) {
const poiTitle = poi.title || `Station ${index + 1}`;
const poiTitle = poi.title || `Streckenpunkt ${index + 1}`;
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
description: poi.description || '',
lat: poi.lat,
@@ -842,7 +914,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
});
const serverPoi = poiPayload.poi || poiPayload;
const serverPoiId = poiId(serverPoi);
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-POI-ID zurückgegeben.`);
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-ID zurückgegeben.`);
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
}
return createdRouteId;
@@ -905,7 +977,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const lat = Number(position?.lat);
const lon = Number(position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setStatus('Für einen neuen Trackpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
setStatus('Für einen neuen Streckenpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
return;
}
@@ -916,31 +988,55 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
lon,
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
});
byId(ids.poiFormTitle).textContent = 'Trackpunkt hinzufügen';
byId(ids.poiFormTitle).textContent = 'Streckenpunkt hinzufügen';
showPoiView();
}
function editPoi(poi) {
state.currentPoi = poi;
fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = 'Trackpunkt bearbeiten';
byId(ids.poiFormTitle).textContent = 'Streckenpunkt bearbeiten';
showPoiView();
}
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
const audio = state.pendingAudioFile
? await ns.LocalRoutes.fileToDataUrl(state.pendingAudioFile)
: existingPoi?.audio || null;
const routeIdentifier = routeId(state.currentRoute);
if (!routeIdentifier) throw new Error('Speichere zuerst die Strecke.');
const storedNow = [];
const replacedAfterSave = [];
let audio = existingPoi?.audio || null;
if (state.pendingAudioFile) {
const mediaId = `${poiIdentifier}-audio`;
const storedAudio = await ns.LocalRoutes.storeMediaFile(
routeIdentifier,
poiIdentifier,
'audio',
mediaId,
state.pendingAudioFile,
state.pendingAudioHandle
);
storedNow.push(storedAudio);
if (audio) replacedAfterSave.push(audio);
audio = storedAudio;
}
const images = [...(existingPoi?.images || [])];
for (const pending of state.pendingImages) {
const image = pending.preview || await ns.LocalRoutes.fileToDataUrl(pending.file);
images.push({
...image,
id: `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`,
caption: ''
});
const mediaId = `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`;
const image = await ns.LocalRoutes.storeMediaFile(
routeIdentifier,
poiIdentifier,
'image',
mediaId,
pending.file,
pending.handle
);
images.push({ ...image, caption: '' });
storedNow.push(image);
}
return { audio, images };
return { audio, images, storedNow, replacedAfterSave };
}
async function persistLocalCurrentRoute() {
@@ -953,17 +1049,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
route: state.currentRoute,
pois: state.pois
});
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
state.pois = saved.pois;
const hydrated = await ns.LocalRoutes.getWithMedia(id) || saved;
state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' };
state.pois = hydrated.pois;
state.currentPoi = currentPoiId
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
: null;
return saved;
return hydrated;
}
async function saveLocalPoi(values) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new TypeError('Für einen GPX-Trackpunkt sind gültige Breiten- und Längengrade erforderlich.');
throw new TypeError('Für einen Streckenpunkt sind gültige Breiten- und Längengrade erforderlich.');
}
const currentId = Number(byId(ids.poiId).value);
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
@@ -972,15 +1069,24 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const savedPoi = {
...existing,
...values,
...media,
audio: media.audio,
images: media.images,
id: identifier,
sequence: existing?.sequence ?? state.pois.length
};
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
if (index >= 0) state.pois[index] = savedPoi;
else state.pois.push(savedPoi);
await persistLocalCurrentRoute();
setStatus('Trackpunkt und zugehörige JSON-Mediendaten lokal gespeichert.');
try {
await persistLocalCurrentRoute();
} catch (error) {
await Promise.allSettled(media.storedNow.map(ns.LocalRoutes.removeMedia));
throw error;
}
await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia));
resetPendingMedia();
setStatus('Streckenpunkt und zugehörige Mediendaten lokal gespeichert.');
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
}
@@ -1013,13 +1119,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const saved = payload.poi || payload;
const savedPoiId = poiId(saved) || currentPoiId;
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
setStatus('Trackpunkt gespeichert.');
setStatus('Streckenpunkt gespeichert.');
await loadRoute({ id: currentRouteId, _source: 'server' });
}
async function savePoi() {
const values = poiFromForm();
if (!values.title) throw new Error('Der Titel des Trackpunkts ist erforderlich.');
if (!values.title) throw new Error('Der Titel des Streckenpunkts ist erforderlich.');
if (state.currentSource === 'local') await saveLocalPoi(values);
else await saveServerPoi(values);
}
@@ -1034,9 +1140,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function removePendingMedia(kind, mediaIndex) {
if (kind === 'audio') {
state.pendingAudioFile = null;
state.pendingAudioHandle = null;
} else {
const index = Number.parseInt(mediaIndex, 10);
if (Number.isInteger(index)) state.pendingImages.splice(index, 1);
if (Number.isInteger(index)) {
const [removed] = state.pendingImages.splice(index, 1);
releasePendingPreview(removed);
}
}
state.selectedMediaKey = null;
renderPoiMediaPreview();
@@ -1046,15 +1156,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function removeExistingLocalMedia(kind, mediaIndex) {
if (!state.currentPoi) return;
const updated = { ...state.currentPoi };
let removedMedia = null;
if (kind === 'audio') {
removedMedia = updated.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);
if (Number.isInteger(index)) {
[removedMedia] = updated.images.splice(index, 1);
}
}
updateCurrentPoi(updated);
await persistLocalCurrentRoute();
if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia);
state.selectedMediaKey = null;
renderPoiMediaPreview();
updatePoiMediaStatus();
@@ -1064,7 +1179,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function removeExistingServerMedia(kind, mediaId) {
const currentRouteId = routeId(state.currentRoute);
const currentPoiId = poiId(state.currentPoi);
if (!currentRouteId || !currentPoiId) throw new Error('Der Trackpunkt ist noch nicht gespeichert.');
if (!currentRouteId || !currentPoiId) throw new Error('Der Streckenpunkt ist noch nicht gespeichert.');
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
const updated = { ...state.currentPoi };
@@ -1118,20 +1233,23 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!currentRouteId || !currentPoiId) return;
if (state.currentSource === 'local') {
const removedPoi = state.currentPoi;
state.pois = state.pois
.filter(poi => poiId(poi) !== currentPoiId)
.map((poi, sequence) => ({ ...poi, sequence }));
state.currentPoi = null;
await persistLocalCurrentRoute();
const removedMedia = [removedPoi?.audio, ...(removedPoi?.images || [])].filter(Boolean);
await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia));
renderPoiList();
showRouteView();
setStatus('Trackpunkt entfernt und lokale Strecke aktualisiert.');
setStatus('Streckenpunkt entfernt und lokale Strecke aktualisiert.');
return;
}
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' });
setStatus('Trackpunkt entfernt.');
setStatus('Streckenpunkt entfernt.');
}
function reorderPois(index, offset) {
@@ -1154,7 +1272,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!currentRouteId) return;
if (state.currentSource === 'local') {
await persistLocalCurrentRoute();
setStatus('Lokale Trackpunkt-Reihenfolge gespeichert.');
setStatus('Lokale Streckenpunkt-Reihenfolge gespeichert.');
return;
}
const jobs = state.pois.map((poi, index) => {
@@ -1188,7 +1306,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const id = routeId(route);
if (!id || routeSource(route) !== 'local') return;
ns.LocalRoutes.remove(id);
await ns.LocalRoutes.remove(id);
if (routeId(state.currentRoute) === id && state.currentSource === routeSource(route)) {
state.currentRoute = null;
@@ -1255,8 +1373,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
activate(byId(ids.addPoi), newPoi);
activate(byId(ids.poiAudioAdd), () => openFilePicker(ids.poiAudio));
activate(byId(ids.poiImagesAdd), () => openFilePicker(ids.poiImages));
activate(byId(ids.poiAudioAdd), () => guarded(() => chooseMedia('audio', ids.poiAudio)));
activate(byId(ids.poiImagesAdd), () => guarded(() => chooseMedia('image', ids.poiImages)));
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));