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

@ -466,6 +466,11 @@ def _migrate(conn_factory):
("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"),
# Tagebuch: Ortsname (POI/Adresse)
("diary", "location_name", "TEXT"),
# Bewertungen: walks + sitters brauchen bewertung + anz_bewertungen
("walks", "bewertung", "REAL DEFAULT 0"),
("walks", "anz_bewertungen", "INTEGER DEFAULT 0"),
("sitters", "bewertung", "REAL DEFAULT 0"),
("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -739,6 +744,36 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type);
""")
# Ratings — einheitliches Bewertungssystem
conn.executescript("""
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
stars INTEGER NOT NULL,
kommentar TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, target_type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_ratings_target ON ratings(target_type, target_id);
""")
logger.info("Migration: ratings Tabelle bereit.")
# Tagebuch: mehrere Mediendateien pro Eintrag
conn.executescript("""
CREATE TABLE IF NOT EXISTS diary_media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE,
url TEXT NOT NULL,
media_type TEXT NOT NULL DEFAULT 'image',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_diary_media_entry ON diary_media(diary_id, sort_order);
""")
logger.info("Migration: diary_media Tabelle bereit.")
# Walk-Einladungen (RSVP)
conn.executescript("""
CREATE TABLE IF NOT EXISTS walk_invitations (

View file

@ -86,10 +86,30 @@ def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
)
def _entry_dict(row, dog_ids_map: dict) -> dict:
def _fetch_media_items(conn, entry_ids: list[int]) -> dict:
"""Gibt {entry_id: [{url, media_type, sort_order, id}, ...]} zurück."""
if not entry_ids:
return {}
ph = ",".join("?" * len(entry_ids))
rows = conn.execute(
f"SELECT id, diary_id, url, media_type, sort_order FROM diary_media "
f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order",
entry_ids
).fetchall()
result = {}
for r in rows:
result.setdefault(r["diary_id"], []).append({
"id": r["id"], "url": r["url"],
"media_type": r["media_type"], "sort_order": r["sort_order"]
})
return result
def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict:
e = dict(row)
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
e["tags"] = json.loads(e["tags"]) if e["tags"] else []
e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
e["media_items"] = (media_map or {}).get(e["id"], [])
return e
@ -122,9 +142,10 @@ async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
(dog_id, dog_id, limit, offset)
).fetchall()
ids = [r["id"] for r in rows]
dogs_map = _fetch_dog_ids(conn, ids)
dogs_map = _fetch_dog_ids(conn, ids)
media_map = _fetch_media_items(conn, ids)
return [_entry_dict(r, dogs_map) for r in rows]
return [_entry_dict(r, dogs_map, media_map) for r in rows]
@router.post("/{dog_id}/diary", status_code=201)
@ -158,9 +179,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
(dog_id,)
).fetchone()
_set_dog_ids(conn, entry["id"], all_dogs)
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
media_map = _fetch_media_items(conn, [entry["id"]])
return _entry_dict(entry, dogs_map)
return _entry_dict(entry, dogs_map, media_map)
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
@ -302,9 +324,10 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
dogs_map = _fetch_dog_ids(conn, [entry_id])
dogs_map = _fetch_dog_ids(conn, [entry_id])
media_map = _fetch_media_items(conn, [entry_id])
return _entry_dict(row, dogs_map)
return _entry_dict(row, dogs_map, media_map)
@router.patch("/{dog_id}/diary/{entry_id}")
@ -345,10 +368,11 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn)
_set_dog_ids(conn, entry_id, all_dogs)
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
dogs_map = _fetch_dog_ids(conn, [entry_id])
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
dogs_map = _fetch_dog_ids(conn, [entry_id])
media_map = _fetch_media_items(conn, [entry_id])
return _entry_dict(row, dogs_map)
return _entry_dict(row, dogs_map, media_map)
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
@ -360,6 +384,16 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user
)
def _guess_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower()
if ct.startswith("video/"):
return "video"
ext = os.path.splitext(filename or "")[1].lower()
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
return "video"
return "image"
@router.post("/{dog_id}/diary/{entry_id}/media")
async def upload_media(dog_id: int, entry_id: int,
file: UploadFile = File(...),
@ -386,29 +420,58 @@ async def upload_media(dog_id: int, entry_id: int,
".mp4",".mov",".webm",".m4v"}:
raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "diary", filename)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "diary", filename)
media_type = _guess_media_type(ct, file.filename or "")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
# Altes Medium von Disk löschen wenn vorhanden
with db() as conn:
old = conn.execute("SELECT media_url FROM diary WHERE id=?", (entry_id,)).fetchone()
if old and old["media_url"]:
old_path = os.path.join(MEDIA_DIR, old["media_url"].lstrip("/media/"))
try: os.remove(old_path)
except OSError: pass
media_url = f"/media/diary/{filename}"
conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
media_url = f"/media/diary/{filename}"
return {"media_url": media_url}
with db() as conn:
# sort_order = nächste freie Position
max_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?",
(entry_id,)
).fetchone()[0]
conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order) VALUES (?,?,?,?)",
(entry_id, media_url, media_type, max_order + 1)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
(entry_id,)
).fetchone()["id"]
return {"id": new_id, "url": media_url, "media_type": media_type, "sort_order": max_order + 1}
@router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
async def delete_media_item(dog_id: int, entry_id: int, media_id: int,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
"SELECT dm.id, dm.url FROM diary_media dm "
"JOIN diary d ON d.id = dm.diary_id "
"LEFT JOIN diary_dogs dd ON dd.diary_id = d.id "
"WHERE dm.id=? AND dm.diary_id=? AND (d.dog_id=? OR dd.dog_id=?)",
(media_id, entry_id, dog_id, dog_id)
).fetchone()
if not row:
raise HTTPException(404, "Medium nicht gefunden.")
file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/"))
try: os.remove(file_path)
except OSError: pass
conn.execute("DELETE FROM diary_media WHERE id=?", (media_id,))
@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204)
async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user)):
async def delete_media_legacy(dog_id: int, entry_id: int, user=Depends(get_current_user)):
"""Legacy-Endpoint: löscht media_url aus dem diary-Datensatz (Rückwärtskompatibilität)."""
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
@ -421,7 +484,3 @@ async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user
try: os.remove(path)
except OSError: pass
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
return unique

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