add delete buttons to editor
Sonarqube Scanner / Build and analyze (push) Successful in 1m4s

This commit is contained in:
Florian Zumpe
2026-09-27 17:59:09 +02:00
parent 6b3f228ad8
commit becd48b487
4 changed files with 504 additions and 63 deletions
+312 -32
View File
@@ -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);