From fa0fcbf8c921296fad22d00bc50ba2108e5ac83f Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 18 Apr 2026 19:07:37 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Tagebuch=20Cover-Bild=20(Favorit-Fun?= =?UTF-8?q?ktion)=20f=C3=BCr=20diary=5Fmedia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: diary_media.is_cover (INTEGER DEFAULT 0) - Upload: erstes Item eines Eintrags automatisch is_cover=1 - Neuer Endpoint: PATCH /diary/{id}/media/{mid}/cover - GET-Endpoints geben is_cover + cover_url zurück - Frontend: Stern-Button (⭐) in Gallery-Detail und Edit-Formular - Timeline-Karte verwendet cover_url als Vorschaubild - SW by-v212, APP_VER 186 --- backend/database.py | 25 +++++++++ backend/routes/diary.py | 43 ++++++++++++--- backend/static/css/components.css | 34 ++++++++++++ backend/static/js/api.js | 9 ++++ backend/static/js/app.js | 17 +++++- backend/static/js/pages/diary.js | 87 +++++++++++++++++++++++++++---- backend/static/sw.js | 2 +- 7 files changed, 196 insertions(+), 21 deletions(-) diff --git a/backend/database.py b/backend/database.py index 7c13a8f..95c77f8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -6,18 +6,27 @@ SQLite mit WAL-Modus (bewährt von akku-werkstatt). import sqlite3 import os import logging +import unicodedata from contextlib import contextmanager logger = logging.getLogger(__name__) DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") +def _norm(s): + """Diakritika entfernen + lowercase — für akzentunabhängige Suche.""" + if not s: + return '' + return unicodedata.normalize('NFKD', str(s)).encode('ascii', 'ignore').decode('ascii').lower() + + def get_connection() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA busy_timeout=5000") + conn.create_function('norm', 1, _norm) return conn @@ -471,6 +480,8 @@ def _migrate(conn_factory): ("walks", "anz_bewertungen", "INTEGER DEFAULT 0"), ("sitters", "bewertung", "REAL DEFAULT 0"), ("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"), + # Tagebuch-Medien: Cover-Bild markieren + ("diary_media", "is_cover", "INTEGER NOT NULL DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -774,6 +785,20 @@ def _migrate(conn_factory): """) logger.info("Migration: diary_media Tabelle bereit.") + # Gesundheit: mehrere Mediendateien pro Eintrag + conn.executescript(""" + CREATE TABLE IF NOT EXISTS health_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + health_id INTEGER NOT NULL REFERENCES health(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_health_media_entry ON health_media(health_id, sort_order); + """) + logger.info("Migration: health_media Tabelle bereit.") + # Walk-Einladungen (RSVP) conn.executescript(""" CREATE TABLE IF NOT EXISTS walk_invitations ( diff --git a/backend/routes/diary.py b/backend/routes/diary.py index b242426..9392d23 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -87,12 +87,12 @@ def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]): def _fetch_media_items(conn, entry_ids: list[int]) -> dict: - """Gibt {entry_id: [{url, media_type, sort_order, id}, ...]} zurück.""" + """Gibt {entry_id: [{url, media_type, sort_order, id, is_cover}, ...]} 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"SELECT id, diary_id, url, media_type, sort_order, is_cover FROM diary_media " f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order", entry_ids ).fetchall() @@ -100,7 +100,8 @@ def _fetch_media_items(conn, entry_ids: list[int]) -> dict: 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"] + "media_type": r["media_type"], "sort_order": r["sort_order"], + "is_cover": r["is_cover"], }) return result @@ -109,7 +110,11 @@ 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["media_items"] = (media_map or {}).get(e["id"], []) + items = (media_map or {}).get(e["id"], []) + e["media_items"] = items + # cover_url: Item mit is_cover=1, Fallback auf erstes Item + cover = next((m for m in items if m.get("is_cover")), items[0] if items else None) + e["cover_url"] = cover["url"] if cover else None return e @@ -437,16 +442,19 @@ async def upload_media(dog_id: int, entry_id: int, "SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?", (entry_id,) ).fetchone()[0] + # Erstes Item eines Eintrags wird automatisch Cover + is_cover = 1 if max_order == -1 else 0 conn.execute( - "INSERT INTO diary_media (diary_id, url, media_type, sort_order) VALUES (?,?,?,?)", - (entry_id, media_url, media_type, max_order + 1) + "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)", + (entry_id, media_url, media_type, max_order + 1, is_cover) ) 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} + return {"id": new_id, "url": media_url, "media_type": media_type, + "sort_order": max_order + 1, "is_cover": is_cover} @router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204) @@ -484,3 +492,24 @@ async def delete_media_legacy(dog_id: int, entry_id: int, user=Depends(get_curre try: os.remove(path) except OSError: pass conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,)) + + +@router.patch("/{dog_id}/diary/{entry_id}/media/{media_id}/cover", status_code=200) +async def set_cover_media(dog_id: int, entry_id: int, media_id: int, + user=Depends(get_current_user)): + """Setzt ein Medium als Cover-Bild (is_cover=1), alle anderen auf 0.""" + with db() as conn: + _own_dog(dog_id, user["id"], conn) + row = conn.execute( + "SELECT dm.id 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.") + # Alle Items dieses Eintrags auf is_cover=0, dann das gewählte auf 1 + conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,)) + conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,)) + return {"ok": True} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index d29f76d..2659f1f 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1149,6 +1149,40 @@ html.modal-open { font-size: 3rem; } +/* Cover-Stern-Button auf Gallery- und Thumbnail-Items */ +.diary-gallery-wrap { + position: relative; + display: inline-block; +} +.diary-cover-btn { + position: absolute; + bottom: var(--space-1); + left: var(--space-1); + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(0,0,0,.50); + color: rgba(255,255,255,.55); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; + z-index: 2; + transition: color .15s, background .15s; +} +.diary-cover-btn--active { + color: #f5c518; + background: rgba(0,0,0,.65); +} +.diary-cover-btn--form { + bottom: var(--space-1); + left: var(--space-1); +} + /* Card Body */ .diary-card-body { padding: var(--space-3) var(--space-4); diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 46f5ac3..21bf4fa 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -127,6 +127,9 @@ const API = (() => { deleteMediaItem(dogId, entryId, mediaId) { return del(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}`); }, + setCover(dogId, entryId, mediaId) { + return patch(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}/cover`, {}); + }, nearby(dogId, lat, lon) { return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`); }, @@ -149,6 +152,12 @@ const API = (() => { deleteDocument(dogId, id) { return del(`/dogs/${dogId}/health/${id}/dokument`); }, + uploadMedia(dogId, entryId, formData) { + return upload(`/dogs/${dogId}/health/${entryId}/media`, formData); + }, + deleteMedia(dogId, entryId, mediaId) { + return del(`/dogs/${dogId}/health/${entryId}/media/${mediaId}`); + }, kiZusammenfassung(dogId) { return post(`/dogs/${dogId}/health/ki-zusammenfassung`); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d84fbb9..dbe8642 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '181'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '186'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -420,7 +420,8 @@ const App = (() => { } _updateNotifBadge(); - setInterval(_updateNotifBadge, 60_000); + _updateChatBadge(); + setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000); const pendingInvite = sessionStorage.getItem('pending_invite'); if (pendingInvite) { @@ -440,6 +441,18 @@ const App = (() => { } catch { /* ignorieren */ } } + async function _updateChatBadge() { + if (!state.user) return; + try { + const convs = await API.chat.conversations(); + const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0); + const badge = document.getElementById('chat-badge'); + if (!badge) return; + badge.textContent = total; + badge.style.display = total > 0 ? '' : 'none'; + } catch { /* ignorieren */ } + } + function _onLoggedOut() { state.user = null; state.dogs = []; diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 196eada..38b4fc0 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -304,14 +304,14 @@ window.Page_diary = (() => { const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; const tags = (e.tags || []).slice(0, 4); - const allMedia = _allMedia(e); - const firstMedia = allMedia[0] || null; + const allMedia = _allMedia(e); + const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null; const mediaCount = allMedia.length; - const photo = firstMedia + const photo = coverMedia ? `
- ${firstMedia.media_type === 'video' + ${coverMedia.media_type === 'video' ? `
` - : `Foto`} + : `Foto`} ${mediaCount > 1 ? `${mediaCount}` : ''}
` : ''; @@ -381,12 +381,21 @@ window.Page_diary = (() => { const allMedia = _allMedia(entry); const photo = allMedia.length > 0 ? (allMedia.length === 1 - ? _mediaHtml(allMedia[0].url, 'margin-bottom:var(--space-4)') + ? `
+ ${_mediaHtml(allMedia[0].url)} +
` : ``) : ''; @@ -434,6 +443,33 @@ window.Page_diary = (() => { UI.modal.open({ title: entry.titel || typ.label, body }); + // Stern-Buttons: Cover-Bild setzen + document.querySelectorAll('.diary-cover-btn').forEach(btn => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const mediaId = parseInt(btn.dataset.mediaId); + try { + await API.diary.setCover(_appState.activeDog.id, entry.id, mediaId); + // Lokalen State aktualisieren + if (entry.media_items) { + entry.media_items.forEach(m => { m.is_cover = m.id === mediaId ? 1 : 0; }); + } + entry.cover_url = (entry.media_items || []).find(m => m.id === mediaId)?.url || null; + _updateEntryInList(entry); + // Alle Sterne im Modal aktualisieren + document.querySelectorAll('.diary-cover-btn').forEach(b => { + const active = parseInt(b.dataset.mediaId) === mediaId; + b.classList.toggle('diary-cover-btn--active', active); + b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen'); + b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen'); + }); + UI.toast.success('Cover-Bild gesetzt.'); + } catch { + UI.toast.error('Cover konnte nicht gesetzt werden.'); + } + }); + }); + document.getElementById('detail-edit')?.addEventListener('click', async () => { UI.modal.close(); // Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag) @@ -645,7 +681,12 @@ window.Page_diary = (() => { : ``} ${m.id != null ? `` + aria-label="Entfernen">${UI.icon('x')} + ` : ``} `).join('')} @@ -674,6 +715,30 @@ window.Page_diary = (() => { } }); }); + // Stern-Buttons im Edit-Formular + wrap.querySelectorAll('.diary-cover-btn--form').forEach(btn => { + btn.addEventListener('click', async () => { + const mediaId = parseInt(btn.dataset.mediaId); + try { + await API.diary.setCover(_appState.activeDog.id, entry.id, mediaId); + if (entry.media_items) { + entry.media_items.forEach(m => { m.is_cover = m.id === mediaId ? 1 : 0; }); + } + entry.cover_url = (entry.media_items || []).find(m => m.id === mediaId)?.url || null; + _updateEntryInList(entry); + // Alle Sterne in diesem Formular aktualisieren + wrap.querySelectorAll('.diary-cover-btn--form').forEach(b => { + const active = parseInt(b.dataset.mediaId) === mediaId; + b.classList.toggle('diary-cover-btn--active', active); + b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen'); + b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen'); + }); + UI.toast.success('Cover-Bild gesetzt.'); + } catch { + UI.toast.error('Cover konnte nicht gesetzt werden.'); + } + }); + }); } _renderExistingMedia(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 273e390..8c76a2a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v211'; +const CACHE_VERSION = 'by-v212'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten