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:
parent
63ab092f5e
commit
fa0fcbf8c9
7 changed files with 196 additions and 21 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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'}">⭐</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'}">⭐</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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue