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
+120 -12
View File
@@ -152,6 +152,7 @@
.editor-button, .editor-button,
.editor-primary-button, .editor-primary-button,
.editor-secondary-button, .editor-secondary-button,
.editor-danger-button,
.editor-icon-button { .editor-icon-button {
border-radius: .75rem; border-radius: .75rem;
font: inherit; font: inherit;
@@ -216,6 +217,122 @@
width: 2rem; 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 { .editor-round-button {
align-items: center; align-items: center;
background: #315d3a; background: #315d3a;
@@ -236,6 +353,9 @@
.editor-round-button:focus-visible, .editor-round-button:focus-visible,
.editor-primary-button:focus-visible, .editor-primary-button:focus-visible,
.editor-secondary-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 { .editor-icon-button:focus-visible {
outline: .2rem solid #f2ca6b; outline: .2rem solid #f2ca6b;
outline-offset: .15rem; outline-offset: .15rem;
@@ -383,12 +503,6 @@
resize: vertical; resize: vertical;
} }
.editor-coordinate-grid {
display: grid;
gap: .55rem;
grid-template-columns: minmax(0, 1fr);
}
.editor-secondary-button, .editor-secondary-button,
.editor-icon-button { .editor-icon-button {
background: #fff; 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) { @media (max-width: 34rem) {
.editor-media-actions { .editor-media-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+10 -9
View File
@@ -391,15 +391,9 @@
<label for="editor-poi-description">Text</label> <label for="editor-poi-description">Text</label>
<textarea id="editor-poi-description" rows="6"></textarea> <textarea id="editor-poi-description" rows="6"></textarea>
<div class="editor-coordinate-grid"> <input id="editor-poi-lat" type="hidden">
<label for="editor-poi-lat">Breite</label> <input id="editor-poi-lon" type="hidden">
<input id="editor-poi-lat" type="number" inputmode="decimal" step="0.000001" required> <input id="editor-poi-radius" type="hidden" value="25">
<label for="editor-poi-lon">Länge</label>
<input id="editor-poi-lon" type="number" inputmode="decimal" step="0.000001" required>
<label for="editor-poi-radius">Auslöseradius in Metern</label>
<input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25">
</div>
<div class="editor-media-actions" aria-label="Medien zur Station hinzufügen"> <div class="editor-media-actions" aria-label="Medien zur Station hinzufügen">
<button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen"> <button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen">
@@ -420,9 +414,16 @@
</div> </div>
<input id="editor-poi-audio" type="file" accept="audio/*" hidden> <input id="editor-poi-audio" type="file" accept="audio/*" hidden>
<input id="editor-poi-images" type="file" accept="image/*" multiple hidden> <input id="editor-poi-images" type="file" accept="image/*" multiple hidden>
<div id="editor-poi-media-preview" class="editor-media-preview" aria-label="Medienvorschau"></div>
<p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p> <p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p>
<div class="editor-form-actions">
<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>
<button id="editor-poi-delete" class="editor-danger-button" type="button" hidden>
<span class="editor-danger-icon" aria-hidden="true">−</span>
<span>POI entfernen</span>
</button>
</div>
</form> </form>
</section> </section>
</div> </div>
+312 -32
View File
@@ -13,6 +13,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
poiAudio: 'editor-poi-audio', poiAudio: 'editor-poi-audio',
poiAudioAdd: 'editor-poi-audio-add', poiAudioAdd: 'editor-poi-audio-add',
poiCancel: 'editor-poi-cancel', poiCancel: 'editor-poi-cancel',
poiDelete: 'editor-poi-delete',
poiDescription: 'editor-poi-description', poiDescription: 'editor-poi-description',
poiForm: 'editor-poi-form', poiForm: 'editor-poi-form',
poiFormTitle: 'editor-poi-form-title', poiFormTitle: 'editor-poi-form-title',
@@ -23,6 +24,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
poiLat: 'editor-poi-lat', poiLat: 'editor-poi-lat',
poiList: 'editor-poi-list', poiList: 'editor-poi-list',
poiLon: 'editor-poi-lon', poiLon: 'editor-poi-lon',
poiMediaPreview: 'editor-poi-media-preview',
poiMediaStatus: 'editor-poi-media-status', poiMediaStatus: 'editor-poi-media-status',
poiRadius: 'editor-poi-radius', poiRadius: 'editor-poi-radius',
poiSave: 'editor-poi-save', poiSave: 'editor-poi-save',
@@ -45,16 +47,24 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const classes = Object.freeze({ const classes = Object.freeze({
button: 'editor-button', button: 'editor-button',
coordinateGrid: 'editor-coordinate-grid',
dialog: 'editor-dialog', dialog: 'editor-dialog',
dialogHeader: 'editor-dialog-header', dialogHeader: 'editor-dialog-header',
dangerButton: 'editor-danger-button',
dangerIcon: 'editor-danger-icon',
dialogShell: 'editor-dialog-shell', dialogShell: 'editor-dialog-shell',
formActions: 'editor-form-actions',
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', mediaActions: 'editor-media-actions',
mediaButton: 'editor-media-button', mediaButton: 'editor-media-button',
mediaIcon: 'editor-media-icon', 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', muted: 'editor-muted',
poiItem: 'editor-poi-item', poiItem: 'editor-poi-item',
poiList: 'editor-poi-list', poiList: 'editor-poi-list',
@@ -75,8 +85,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
currentPoi: null, currentPoi: null,
currentRoute: null, currentRoute: null,
currentSource: null, currentSource: null,
pendingAudioFile: null,
pendingImages: [],
pois: [], pois: [],
routes: [] routes: [],
selectedMediaKey: null
}; };
function ready(callback) { function ready(callback) {
@@ -261,6 +274,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return handleResponse(response); 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) { function routeArray(payload) {
if (Array.isArray(payload)) return payload; if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.routes)) return payload.routes; 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) { function updatePoiMediaStatus(poi = state.currentPoi) {
const status = byId(ids.poiMediaStatus); const status = byId(ids.poiMediaStatus);
if (!status) return; if (!status) return;
const selectedAudio = byId(ids.poiAudio)?.files?.[0]; const existingAudio = existingAudioUrl(poi) ? 1 : 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 existingImages = Array.isArray(poi?.images) ? poi.images.length : 0;
const audioCount = existingAudio + (state.pendingAudioFile ? 1 : 0);
const imageCount = existingImages + state.pendingImages.length;
const parts = []; const parts = [];
if (selectedAudio) parts.push(`Audio ausgewählt: ${selectedAudio.name || 'Audiodatei'}`); if (audioCount) parts.push(`${audioCount} Audio${audioCount === 1 ? 'aufnahme' : 'aufnahmen'}`);
else if (hasExistingAudio) parts.push('Audio vorhanden'); if (imageCount) parts.push(`${imageCount} Bild${imageCount === 1 ? '' : 'er'}`);
if (selectedImages.length) parts.push(`${selectedImages.length} Bild${selectedImages.length === 1 ? '' : 'er'} ausgewählt`); status.textContent = parts.length
else if (existingImages) parts.push(`${existingImages} Bild${existingImages === 1 ? '' : 'er'} vorhanden`); ? `${parts.join(' · ')}. Vorschau antippen, um die Entfernen-Schaltfläche einzublenden.`
: 'Noch keine Medien vorhanden.';
status.textContent = parts.length ? parts.join(' · ') : 'Noch keine neuen Medien ausgewählt.';
} }
function openFilePicker(inputId) { function openFilePicker(inputId) {
@@ -479,17 +610,41 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function fillPoiForm(poi) { function fillPoiForm(poi) {
resetPendingMedia();
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);
byId(ids.poiDescription).value = text(poi?.description || poi?.text); byId(ids.poiDescription).value = text(poi?.description || poi?.text);
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude); byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude); byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
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 = ''; setHidden(byId(ids.poiDelete), !poiId(poi));
byId(ids.poiImages).value = ''; renderPoiMediaPreview(poi);
updatePoiMediaStatus(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) { async function loadRoute(route) {
const id = routeId(route); const id = routeId(route);
if (!id) return; if (!id) return;
@@ -602,12 +757,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function newPoi() { function newPoi() {
state.currentPoi = null;
const position = currentPosition(); 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({ fillPoiForm({
lat: position?.lat ?? '', lat,
lon: position?.lon ?? '', lon,
triggerRadiusM: 25 triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
}); });
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen'; byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
showPoiView(); showPoiView();
@@ -621,15 +784,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
async function mediaFromLocalForm(existingPoi, poiIdentifier) { async function mediaFromLocalForm(existingPoi, poiIdentifier) {
const audioFile = byId(ids.poiAudio)?.files?.[0]; const audio = state.pendingAudioFile
const imageFiles = Array.from(byId(ids.poiImages)?.files || []); ? await ns.LocalRoutes.fileToDataUrl(state.pendingAudioFile)
const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null; : existingPoi?.audio || null;
const images = [...(existingPoi?.images || [])]; const images = [...(existingPoi?.images || [])];
for (const imageFile of imageFiles) { for (const pending of state.pendingImages) {
const image = await ns.LocalRoutes.fileToDataUrl(imageFile); const image = pending.preview || await ns.LocalRoutes.fileToDataUrl(pending.file);
images.push({ images.push({
...image, ...image,
id: `${poiIdentifier}-image-${Date.now()}-${images.length}`, id: `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`,
caption: '' caption: ''
}); });
} }
@@ -639,6 +802,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function persistLocalCurrentRoute() { async function persistLocalCurrentRoute() {
const id = routeId(state.currentRoute); const id = routeId(state.currentRoute);
if (!id) throw new Error('Speichere zuerst die Strecke.'); if (!id) throw new Error('Speichere zuerst die Strecke.');
const currentPoiId = poiId(state.currentPoi);
const saved = ns.LocalRoutes.save({ const saved = ns.LocalRoutes.save({
id, id,
gpx: state.currentRoute.gpx, 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.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
state.pois = saved.pois; state.pois = saved.pois;
state.currentPoi = currentPoiId
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
: null;
return saved; return saved;
} }
@@ -673,19 +840,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' }); 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(); const data = new FormData();
data.append(fieldName, file); data.append(fieldName, file);
return sendForm(path, 'POST', data); return sendForm(path, method, data);
} }
async function uploadPoiMedia(currentRouteId, currentPoiId) { 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)}`; const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
if (audio) await uploadOne(`${basePath}/audio`, 'audio', audio); if (state.pendingAudioFile) {
for (const image of images) { const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST';
await uploadOne(`${basePath}/pictures`, 'picture', image); 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); 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) { function reorderPois(index, offset) {
const target = index + offset; const target = index + offset;
if (target < 0 || target >= state.pois.length) return false; 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.addPoi), newPoi);
activate(byId(ids.poiAudioAdd), () => openFilePicker(ids.poiAudio)); activate(byId(ids.poiAudioAdd), () => openFilePicker(ids.poiAudio));
activate(byId(ids.poiImagesAdd), () => openFilePicker(ids.poiImages)); activate(byId(ids.poiImagesAdd), () => openFilePicker(ids.poiImages));
byId(ids.poiAudio)?.addEventListener('change', () => updatePoiMediaStatus()); byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
byId(ids.poiImages)?.addEventListener('change', () => updatePoiMediaStatus()); 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.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);
+61 -9
View File
@@ -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-images-add"[^>]+type="button"/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"[^>]+hidden/); 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-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.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i); 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-poi-list/);
assert.match(css, /\.editor-route-group/); assert.match(css, /\.editor-route-group/);
assert.match(css, /\.editor-media-button/); 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, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/); 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, /\/pictures/);
assert.match(ui, /'picture'/); assert.match(ui, /'picture'/);
assert.match(ui, /'audio'/); assert.match(ui, /'audio'/);
assert.match(ui, /method:\s*'DELETE'/);
assert.match(ui, /position\?\.accuracy/);
}); });
class FakeElement extends EventTarget { class FakeElement extends EventTarget {
@@ -167,6 +178,7 @@ function buildEditorContext() {
'editor-poi-audio', 'editor-poi-audio',
'editor-poi-audio-add', 'editor-poi-audio-add',
'editor-poi-cancel', 'editor-poi-cancel',
'editor-poi-delete',
'editor-poi-description', 'editor-poi-description',
'editor-poi-form', 'editor-poi-form',
'editor-poi-form-title', 'editor-poi-form-title',
@@ -177,6 +189,7 @@ function buildEditorContext() {
'editor-poi-lat', 'editor-poi-lat',
'editor-poi-list', 'editor-poi-list',
'editor-poi-lon', 'editor-poi-lon',
'editor-poi-media-preview',
'editor-poi-media-status', 'editor-poi-media-status',
'editor-poi-radius', 'editor-poi-radius',
'editor-poi-title', '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 (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|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}` }; return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
} }
@@ -270,7 +286,7 @@ function buildEditorContext() {
globalThis: null, globalThis: null,
structuredClone, structuredClone,
Wegwichtel: { Wegwichtel: {
App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) }, App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
Config: { apiBase: '/api/' }, Config: { apiBase: '/api/' },
LocalRoutes 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()); elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-audio').clickCount, 1); assert.equal(elements.get('editor-poi-audio').clickCount, 1);
assert.equal(elements.get('editor-poi-images').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-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-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').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').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()); elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick(); await tick();
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.length, 1);
assert.equal(local.pois[0].title, 'Aussicht'); assert.equal(local.pois[0].title, 'Aussicht');
assert.equal(local.pois[0].lat, 50.51); assert.equal(local.pois[0].lat, 50.123456789);
assert.equal(local.pois[0].lon, 10.52); assert.equal(local.pois[0].lon, 10.987654321);
assert.equal(local.pois[0].triggerRadiusM, 35); assert.equal(local.pois[0].triggerRadiusM, 4.321);
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/); assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
assert.equal(local.pois[0].images.length, 1); assert.equal(local.pois[0].images.length, 1);
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/); 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(); context.Wegwichtel.EditorUi.close();
assert.equal(elements.get('editor-dialog').open, false); assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');