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:
parent
6581a9a88c
commit
63ab092f5e
7 changed files with 367 additions and 165 deletions
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue