Feature: Tagebuch Cover-Bild (Favorit-Funktion) für diary_media

- 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
This commit is contained in:
rene 2026-04-18 19:07:37 +02:00
parent 63ab092f5e
commit fa0fcbf8c9
7 changed files with 196 additions and 21 deletions

View file

@ -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 (

View file

@ -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}

View file

@ -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);

View file

@ -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`);
},

View file

@ -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 = [];

View file

@ -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
? `<div class="diary-card-photo">
${firstMedia.media_type === 'video'
${coverMedia.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="${firstMedia.url}" alt="Foto" loading="lazy">`}
: `<img src="${e.cover_url || coverMedia.url}" alt="Foto" loading="lazy">`}
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`
: '';
@ -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)')
? `<div style="position:relative;margin-bottom:var(--space-4)">
${_mediaHtml(allMedia[0].url)}
</div>`
: `<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('')}
${allMedia.map(m => `
<div class="diary-gallery-wrap" style="position:relative">
${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">`}
<button type="button"
class="diary-cover-btn${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}">&#11088;</button>
</div>`).join('')}
</div>`)
: '';
@ -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 = (() => {
: `<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>`
aria-label="Entfernen">${UI.icon('x')}</button>
<button type="button"
class="diary-cover-btn diary-cover-btn--form${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}">&#11088;</button>`
: `<button type="button" class="diary-media-thumb-del" data-legacy="1"
aria-label="Entfernen">${UI.icon('x')}</button>`}
</div>`).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();

View file

@ -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