Feature: Tagebuch Multi-Medien (beliebig viele Fotos/Videos pro Eintrag)

- Backend: neue Tabelle diary_media (Migration), upload_media schreibt
  jetzt in diary_media statt media_url; neuer DELETE-Endpoint
  /diary/{id}/media/{media_id}; alle GET-Endpoints liefern media_items[].
- Frontend: Multi-Upload-Grid im Formular mit Vorschau und X-Button
  zum Entfernen vor dem Speichern; bestehende Medien im Edit-Modus
  einzeln löschbar; Detail-Ansicht zeigt horizontale Scroll-Galerie
  bei mehreren Medien; Karten-Badge zeigt Anzahl bei > 1 Medium.
- Rückwärtskompatibilität: Einträge mit media_url werden weiterhin
  korrekt angezeigt.
- SW by-v211, APP_VER 181
This commit is contained in:
rene 2026-04-18 18:45:48 +02:00
parent 6581a9a88c
commit 63ab092f5e
7 changed files with 367 additions and 165 deletions

View file

@ -1065,6 +1065,79 @@ html.modal-open {
}
.diary-media-pick-btn .ph-icon { font-size: 1.5rem; }
/* Multi-Medien: Formular-Grid (Thumbnails beim Erstellen/Bearbeiten) */
.diary-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.diary-media-thumb-wrap {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--c-surface-2);
}
.diary-media-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.diary-media-thumb-del {
position: absolute;
top: var(--space-1);
right: var(--space-1);
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,.55);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
}
.diary-media-thumb-del .ph-icon { font-size: .9rem; }
/* Medien-Zähler-Badge auf der Karte */
.diary-card-photo { position: relative; }
.diary-card-media-count {
position: absolute;
bottom: var(--space-1);
right: var(--space-1);
background: rgba(0,0,0,.55);
color: #fff;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
padding: 2px 6px;
border-radius: 12px;
pointer-events: none;
}
/* Detail-Ansicht: horizontale Scroll-Galerie */
.diary-gallery {
display: flex;
gap: var(--space-2);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
border-radius: var(--radius-md);
}
.diary-gallery-item {
flex: 0 0 auto;
width: min(80vw, 320px);
max-height: 260px;
object-fit: cover;
border-radius: var(--radius-md);
scroll-snap-align: start;
display: block;
}
.diary-card-video-thumb {
width: 100%;
height: 100%;

View file

@ -124,6 +124,9 @@ const API = (() => {
deleteMedia(dogId, id) {
return del(`/dogs/${dogId}/diary/${id}/media`);
},
deleteMediaItem(dogId, entryId, mediaId) {
return del(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}`);
},
nearby(dogId, lat, lon) {
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
},

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '179'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '181'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -35,6 +35,18 @@ window.Page_diary = (() => {
: `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`;
}
/** Alle Mediendateien eines Eintrags normalisiert als Array zurückgeben.
* Rückwärtskompatibel: wenn media_items leer, aber media_url gesetzt altes Format. */
function _allMedia(entry) {
const items = entry.media_items || [];
if (items.length > 0) return items;
if (entry.media_url) {
return [{ id: null, url: entry.media_url,
media_type: _isVideo(entry.media_url) ? 'video' : 'image', sort_order: 0 }];
}
return [];
}
const TYPEN = {
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' },
@ -292,11 +304,15 @@ window.Page_diary = (() => {
const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : '';
const tags = (e.tags || []).slice(0, 4);
const photo = e.media_url
const allMedia = _allMedia(e);
const firstMedia = allMedia[0] || null;
const mediaCount = allMedia.length;
const photo = firstMedia
? `<div class="diary-card-photo">
${_isVideo(e.media_url)
${firstMedia.media_type === 'video'
? `<div class="diary-card-video-thumb"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg></div>`
: `<img src="${e.media_url}" alt="Foto" loading="lazy">`}
: `<img src="${firstMedia.url}" alt="Foto" loading="lazy">`}
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`
: '';
@ -362,8 +378,16 @@ window.Page_diary = (() => {
const isMile = entry.is_milestone || entry.typ === 'meilenstein';
const tags = (entry.tags || []);
const photo = entry.media_url
? _mediaHtml(entry.media_url, 'margin-bottom:var(--space-4)')
const allMedia = _allMedia(entry);
const photo = allMedia.length > 0
? (allMedia.length === 1
? _mediaHtml(allMedia[0].url, 'margin-bottom:var(--space-4)')
: `<div class="diary-gallery" style="margin-bottom:var(--space-4)">
${allMedia.map(m => m.media_type === 'video'
? `<video src="${m.url}" controls playsinline class="diary-gallery-item"></video>`
: `<img src="${m.url}" alt="Foto" class="diary-gallery-item">`
).join('')}
</div>`)
: '';
// Hunde-Anzeige wenn mehrere beteiligt
@ -506,9 +530,9 @@ window.Page_diary = (() => {
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary flex-1" id="diary-location-btn">
<button type="button" class="btn btn-secondary btn-sm" id="diary-location-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-btn-label">${entry?.gps_lat ? 'POI suchen' : 'GPS → POI suchen'}</span>
<span id="diary-location-btn-label">POI suchen</span>
</button>
</div>
<div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
@ -525,24 +549,20 @@ window.Page_diary = (() => {
</button>
</div>
<div class="form-group">
<label class="form-label">Foto / Video <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Fotos / Videos <span style="color:var(--c-text-secondary)">(optional)</span></label>
${isEdit && entry.media_url ? `
<div id="diary-current-media" style="position:relative;margin-bottom:var(--space-2)">
${_mediaHtml(entry.media_url, 'max-height:200px;object-fit:cover')}
<button type="button" class="btn btn-danger btn-sm" id="diary-media-delete"
style="position:absolute;top:var(--space-2);right:var(--space-2)">
${UI.icon('trash')}
</button>
</div>
` : ''}
<!-- Bestehende Medien (Edit-Modus) -->
<div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckte Inputs -->
<input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none">
<input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none">
<!-- Auswahlbuttons immer sichtbar -->
<div id="diary-media-btns" class="diary-media-picker">
<div class="diary-media-picker" style="margin-top:var(--space-2)">
<button type="button" class="diary-media-pick-btn" id="diary-btn-camera">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
Kamera
@ -556,14 +576,6 @@ window.Page_diary = (() => {
Datei
</button>
</div>
<div id="diary-media-preview" style="display:none;margin-top:var(--space-2);position:relative">
<img id="diary-photo-preview" style="display:none;width:100%;max-height:200px;object-fit:cover;border-radius:var(--radius-md)">
<video id="diary-video-preview" style="display:none;width:100%;max-height:200px;border-radius:var(--radius-md)" controls playsinline></video>
<button type="button" id="diary-preview-clear"
style="position:absolute;top:var(--space-2);right:var(--space-2)"
class="btn btn-danger btn-sm">${UI.icon('x')}</button>
</div>
</div>
</form>
`;
@ -587,108 +599,118 @@ window.Page_diary = (() => {
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// Media-Inputs + Vorschau
const mediaInput = document.getElementById('diary-media-input');
const cameraInput = document.getElementById('diary-camera-input');
const photoPreview = document.getElementById('diary-photo-preview');
const videoPreview = document.getElementById('diary-video-preview');
const previewWrap = document.getElementById('diary-media-preview');
const mediaBtns = document.getElementById('diary-media-btns');
// ---- Multi-Media-Verwaltung ----
const mediaInput = document.getElementById('diary-media-input');
const cameraInput = document.getElementById('diary-camera-input');
function _showPreview(file) {
if (!file) return;
previewWrap.style.display = '';
if (file.type.startsWith('video/')) {
photoPreview.style.display = 'none';
videoPreview.style.display = '';
videoPreview.src = URL.createObjectURL(file);
} else {
videoPreview.style.display = 'none';
photoPreview.style.display = '';
photoPreview.src = URL.createObjectURL(file);
}
// Neue Dateien die noch nicht hochgeladen wurden
const _newFiles = [];
function _renderNewGrid() {
const grid = document.getElementById('diary-new-media-grid');
if (!grid) return;
if (_newFiles.length === 0) { grid.style.display = 'none'; grid.innerHTML = ''; return; }
grid.style.display = '';
grid.innerHTML = _newFiles.map((f, i) => {
const objUrl = URL.createObjectURL(f);
const thumb = f.type.startsWith('video/')
? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${objUrl}" alt="" class="diary-media-thumb">`;
return `<div class="diary-media-thumb-wrap" data-new-idx="${i}">
${thumb}
<button type="button" class="diary-media-thumb-del" data-new-idx="${i}"
aria-label="Entfernen">${UI.icon('x')}</button>
</div>`;
}).join('');
grid.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.newIdx);
_newFiles.splice(idx, 1);
_renderNewGrid();
});
});
}
function _showAlbumBtn(file) {
// Vorherigen Button entfernen falls vorhanden
document.getElementById('diary-save-album-btn')?.remove();
// Nur anbieten wenn Share-API File-Support hat ODER als Download-Fallback
const canShare = navigator.canShare && navigator.canShare({ files: [file] });
const canDownload = true; // <a download> funktioniert immer als Fallback
if (!canShare && !canDownload) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'diary-save-album-btn';
btn.className = 'btn btn-secondary btn-sm';
btn.style.cssText = 'display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-2);width:100%';
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
<span>${canShare ? 'Zum Fotoalbum hinzufügen' : 'Foto herunterladen'}</span>`;
btn.addEventListener('click', () => UI.saveToAlbum(file));
previewWrap.after(btn);
// Bestehende Medien im Edit-Modus rendern
function _renderExistingMedia() {
const wrap = document.getElementById('diary-existing-media');
if (!wrap) return;
const items = isEdit ? _allMedia(entry) : [];
if (items.length === 0) { wrap.innerHTML = ''; return; }
const grid = `<div class="diary-media-grid" style="margin-bottom:var(--space-2)">
${items.map(m => `
<div class="diary-media-thumb-wrap" data-media-id="${m.id || ''}">
${m.media_type === 'video'
? `<video src="${m.url}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${m.url}" alt="" class="diary-media-thumb">`}
${m.id != null
? `<button type="button" class="diary-media-thumb-del" data-media-id="${m.id}"
aria-label="Entfernen">${UI.icon('x')}</button>`
: `<button type="button" class="diary-media-thumb-del" data-legacy="1"
aria-label="Entfernen">${UI.icon('x')}</button>`}
</div>`).join('')}
</div>`;
wrap.innerHTML = grid;
wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', async () => {
const wrap2 = btn.closest('.diary-media-thumb-wrap');
const mediaId = btn.dataset.mediaId ? parseInt(btn.dataset.mediaId) : null;
const isLegacy = !!btn.dataset.legacy;
btn.disabled = true;
try {
if (mediaId != null) {
await API.diary.deleteMediaItem(_appState.activeDog.id, entry.id, mediaId);
// aus entry.media_items entfernen
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
} else if (isLegacy) {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
}
wrap2.remove();
UI.toast.success('Medium entfernt.');
} catch (e) {
btn.disabled = false;
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
});
}
_renderExistingMedia();
function _addFiles(fileList) {
for (const f of fileList) _newFiles.push(f);
_renderNewGrid();
}
mediaInput?.addEventListener('change', () => {
_showPreview(mediaInput.files[0]);
// Kein Album-Button bei Mediathek-Picks (Fotos sind bereits dort)
document.getElementById('diary-save-album-btn')?.remove();
});
cameraInput?.addEventListener('change', () => {
// Auswahl in mediaInput spiegeln damit Submit-Handler nur einen Ort abfragt
const dt = new DataTransfer();
if (cameraInput.files[0]) dt.items.add(cameraInput.files[0]);
mediaInput.files = dt.files;
_showPreview(cameraInput.files[0]);
// Album-Button nach Kamera-Aufnahme anzeigen
if (cameraInput.files[0]) _showAlbumBtn(cameraInput.files[0]);
});
document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click());
document.getElementById('diary-btn-library')?.addEventListener('click', () => {
// Kein capture → iOS zeigt Mediathek-Auswahl, Android zeigt Galerie
function _openPicker(opts = {}) {
const tmp = document.createElement('input');
tmp.type = 'file'; tmp.accept = 'image/*,video/*'; tmp.style.display = 'none';
if (opts.capture) tmp.setAttribute('capture', opts.capture);
if (opts.noAccept) tmp.removeAttribute('accept');
tmp.addEventListener('change', () => {
const dt = new DataTransfer();
if (tmp.files[0]) dt.items.add(tmp.files[0]);
mediaInput.files = dt.files;
_showPreview(tmp.files[0]);
_addFiles(tmp.files);
tmp.remove();
});
document.body.appendChild(tmp);
tmp.click();
}
cameraInput?.addEventListener('change', () => {
if (cameraInput.files.length) {
_addFiles(cameraInput.files);
cameraInput.value = '';
}
});
document.getElementById('diary-btn-file')?.addEventListener('click', () => {
mediaInput.removeAttribute('accept');
mediaInput.click();
mediaInput.setAttribute('accept', 'image/*,video/*');
mediaInput?.addEventListener('change', () => {
if (mediaInput.files.length) {
_addFiles(mediaInput.files);
mediaInput.value = '';
}
});
document.getElementById('diary-preview-clear')?.addEventListener('click', () => {
previewWrap.style.display = 'none';
photoPreview.src = ''; videoPreview.src = '';
mediaInput.value = '';
document.getElementById('diary-save-album-btn')?.remove();
});
// "Entfernen"-Button löscht Medium direkt
document.getElementById('diary-media-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: `${_isVideo(entry.media_url) ? 'Video' : 'Foto'} entfernen?`,
message: 'Das Medium wird dauerhaft gelöscht.',
confirmText: 'Entfernen', danger: true,
});
if (!ok) return;
try {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
const mediaDiv = document.getElementById('diary-current-media');
if (mediaDiv) mediaDiv.remove();
const replaceBtn = document.getElementById('diary-media-replace');
if (replaceBtn) replaceBtn.remove();
mediaInput.style.display = '';
UI.toast.success('Medium entfernt.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click());
document.getElementById('diary-btn-library')?.addEventListener('click', () => _openPicker({}));
document.getElementById('diary-btn-file') ?.addEventListener('click', () => _openPicker({ noAccept: true }));
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
@ -752,7 +774,7 @@ window.Page_diary = (() => {
_locLat = null; _locLon = null; _locName = null;
document.getElementById('diary-location-chip-wrap').style.display = 'none';
document.getElementById('diary-location-suggestions').style.display = 'none';
document.getElementById('diary-location-btn-label').textContent = 'GPS → POI suchen';
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); }
});
@ -882,33 +904,43 @@ window.Page_diary = (() => {
location_name: _locName,
};
const mediaFile = mediaInput?.files[0];
async function _uploadNewFiles(entryId) {
let failCount = 0;
const uploaded = [];
for (const file of _newFiles) {
try {
const formData = new FormData();
formData.append('file', file);
const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData);
uploaded.push(m);
} catch {
failCount++;
}
}
if (failCount > 0) {
UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`);
}
return uploaded;
}
if (isEdit) {
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload);
if (mediaFile) {
try {
const fd2 = new FormData();
fd2.append('file', mediaFile);
const media = await API.diary.uploadMedia(_appState.activeDog.id, entry.id, fd2);
updated.media_url = media.media_url;
} catch {
UI.toast.warning('Gespeichert, Medium konnte nicht hochgeladen werden.');
}
if (_newFiles.length > 0) {
const uploaded = await _uploadNewFiles(entry.id);
if (!updated.media_items) updated.media_items = [];
updated.media_items.push(...uploaded);
} else {
// media_items aus dem aktuellen entry-State übernehmen (evtl. gelöscht via X-Button)
updated.media_items = entry.media_items || updated.media_items || [];
updated.media_url = entry.media_url ?? updated.media_url;
}
_updateEntryInList(updated);
UI.toast.success('Eintrag gespeichert.');
} else {
const created = await API.diary.create(_appState.activeDog.id, payload);
if (mediaFile) {
try {
const fd2 = new FormData();
fd2.append('file', mediaFile);
const media = await API.diary.uploadMedia(_appState.activeDog.id, created.id, fd2);
created.media_url = media.media_url;
} catch {
UI.toast.warning('Eintrag erstellt, Medium konnte nicht hochgeladen werden.');
}
if (_newFiles.length > 0) {
const uploaded = await _uploadNewFiles(created.id);
created.media_items = uploaded;
}
_entries.unshift(created);
UI.toast.success('Eintrag erstellt.');

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v210';
const CACHE_VERSION = 'by-v211';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten