diff --git a/backend/routes/health.py b/backend/routes/health.py index e758cf8..185c363 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -11,6 +11,8 @@ from auth import get_current_user router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} + # Erlaubte Typen TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"} @@ -70,7 +72,7 @@ class HealthUpdate(BaseModel): # ------------------------------------------------------------------ -# Hilfsfunktion: Zugriffscheck Dog → User +# Hilfsfunktionen # ------------------------------------------------------------------ def _check_dog_owner(conn, dog_id: int, user_id: int): dog = conn.execute( @@ -81,6 +83,30 @@ def _check_dog_owner(conn, dog_id: int, user_id: int): return dog +def _fetch_media_items(conn, entry_ids: list) -> dict: + """Gibt {health_id: [{id, url, media_type}, ...]} zurück.""" + if not entry_ids: + return {} + ph = ",".join("?" * len(entry_ids)) + rows = conn.execute( + f"SELECT id, health_id, url, media_type FROM health_media " + f"WHERE health_id IN ({ph}) ORDER BY health_id, sort_order", + entry_ids + ).fetchall() + result = {} + for r in rows: + result.setdefault(r["health_id"], []).append({ + "id": r["id"], "url": r["url"], "media_type": r["media_type"] + }) + return result + + +def _entry_with_media(row, media_map: dict) -> dict: + e = dict(row) + e["media_items"] = media_map.get(e["id"], []) + return e + + # ------------------------------------------------------------------ # GET /api/dogs/{dog_id}/health # ------------------------------------------------------------------ @@ -99,7 +125,9 @@ async def list_health(dog_id: int, typ: Optional[str] = None, "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", (dog_id,) ).fetchall() - return [dict(r) for r in rows] + ids = [r["id"] for r in rows] + media_map = _fetch_media_items(conn, ids) + return [_entry_with_media(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -130,7 +158,8 @@ async def create_health(dog_id: int, data: HealthCreate, "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) ).fetchone() - return dict(row) + media_map = _fetch_media_items(conn, [row["id"]]) + return _entry_with_media(row, media_map) # ------------------------------------------------------------------ @@ -155,7 +184,8 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate, values = list(updates.values()) + [entry_id] conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values) row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone() - return dict(row) + media_map = _fetch_media_items(conn, [entry_id]) + return _entry_with_media(row, media_map) # ------------------------------------------------------------------ @@ -243,6 +273,77 @@ async def upload_dokument( return {"datei_url": datei_url, "datei_typ": datei_typ} +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health/{entry_id}/media — Datei-Upload (Multi) +# ------------------------------------------------------------------ +@router.post("/{dog_id}/health/{entry_id}/media") +async def upload_media( + dog_id: int, + entry_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + entry = conn.execute( + "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + + ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg" + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.") + + filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "health", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "wb") as f: + f.write(await file.read()) + + media_url = f"/media/health/{filename}" + media_type = "pdf" if ext == ".pdf" else "image" + + with db() as conn: + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM health_media WHERE health_id=?", + (entry_id,) + ).fetchone()[0] + conn.execute( + "INSERT INTO health_media (health_id, url, media_type, sort_order) VALUES (?,?,?,?)", + (entry_id, media_url, media_type, max_order + 1) + ) + new_id = conn.execute( + "SELECT id FROM health_media WHERE health_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} + + +# ------------------------------------------------------------------ +# DELETE /api/dogs/{dog_id}/health/{entry_id}/media/{media_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/health/{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: + _check_dog_owner(conn, dog_id, user["id"]) + row = conn.execute( + "SELECT hm.id, hm.url FROM health_media hm " + "JOIN health h ON h.id = hm.health_id " + "WHERE hm.id=? AND hm.health_id=? AND h.dog_id=?", + (media_id, entry_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 health_media WHERE id=?", (media_id,)) + + # ------------------------------------------------------------------ # GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf # ------------------------------------------------------------------ diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 2659f1f..610cd46 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1419,6 +1419,104 @@ html.modal-open { flex-shrink: 0; } +/* Health Multi-Media — Upload-Grid im Formular */ +.health-media-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-1); +} +.health-media-thumb { + position: relative; + width: 72px; + height: 72px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--c-surface-2); + flex-shrink: 0; + border: 1px solid var(--c-border-light); +} +.health-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.health-media-thumb-pdf { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: var(--space-1); + font-size: 1.4rem; + color: var(--c-text-secondary); +} +.health-media-thumb-pdf span { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); +} +.health-media-remove { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(0,0,0,0.55); + color: #fff; + border: none; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.health-media-thumb--pending { + opacity: 0.75; + border-style: dashed; +} +.health-media-thumb--pending small { + position: absolute; + bottom: 2px; + left: 2px; + right: 2px; + font-size: 9px; + text-align: center; + color: var(--c-text-muted); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Health Media-Galerie in Detail-Modal */ +.health-media-gallery { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.health-media-gallery-img { + display: block; + width: calc(50% - var(--space-1)); + max-width: 160px; + border-radius: var(--radius-md); + overflow: hidden; + flex-shrink: 0; +} +.health-media-gallery-img img { + width: 100%; + height: 100px; + object-fit: cover; + display: block; + border-radius: var(--radius-md); +} +.health-media-gallery-pdf { + align-self: flex-start; +} + /* Detail-Dialog DL */ .health-detail-dl { display: grid; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index dbe8642..4605477 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 = '186'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '187'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 36c55c0..c266981 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -813,30 +813,42 @@ window.Page_health = (() => { }); const items = entries.map(e => { - const isPdf = e.datei_typ === 'pdf'; - const hasFile = !!e.datei_url; + // media_items bevorzugen, legacy datei_url als Fallback + const mediaList = e.media_items?.length + ? e.media_items + : (e.datei_url ? [{ id: null, url: e.datei_url, media_type: e.datei_typ || 'image' }] : []); + const firstImg = mediaList.find(m => m.media_type !== 'pdf'); + const hasPdf = mediaList.some(m => m.media_type === 'pdf'); + const count = mediaList.length; + return `
- ${hasFile && !isPdf - ? `Vorschau` : `
`}
${_esc(e.bezeichnung)}
-
${UI.time.format(e.datum + 'T00:00:00')}
+
+ ${UI.time.format(e.datum + 'T00:00:00')} + ${count > 1 ? ` · ${count} Dateien` : ''} +
${e.notiz ? `
${_esc(e.notiz)}
` : ''} - ${hasFile + ${count ? `
- - ${isPdf ? ' PDF öffnen' : ' Bild öffnen'} - - + ${mediaList.slice(0, 3).map(m => m.media_type === 'pdf' + ? ` + PDF + ` + : ` + Bild + ` + ).join('')}
` : `Noch keine Datei hochgeladen`}
@@ -902,14 +914,29 @@ window.Page_health = (() => { const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0]; const fields = _detailFields(entry); + // Media-Items zusammenstellen (neue + legacy) + const mediaItems = entry.media_items?.length + ? entry.media_items + : (entry.datei_url ? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image' }] : []); + + const mediaHtml = mediaItems.length + ? `` + : ''; + const body = `
${fields} - ${entry.datei_url - ? (entry.datei_typ === 'pdf' - ? ` PDF öffnen` - : `Dokument`) - : ''} + ${mediaHtml}
`; @@ -989,15 +1016,42 @@ window.Page_health = (() => {
`; - const uploadField = t === 'dokument' ? ` -
+ // Multi-Upload-Bereich — zeige vorhandene media_items + neuen Upload + const existingMedia = (entry?.media_items || []); + const legacyFile = (!existingMedia.length && entry?.datei_url) + ? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image', _legacy: true }] + : []; + const allMedia = [...existingMedia, ...legacyFile]; + + const mediaThumbsHtml = allMedia.map(m => { + const isImg = m.media_type !== 'pdf'; + const removeBtn = m.id + ? `` + : ''; + return `
+ ${isImg + ? `Vorschau` + : `
PDF
`} + ${removeBtn} +
`; + }).join(''); + + const uploadField = ` +
- +
${mediaThumbsHtml}
+ +
- ` : ''; + `; const body = `
@@ -1030,6 +1084,48 @@ window.Page_health = (() => { _activeTab = 'praxen'; _renderTab(); }); + // File-Input: Vorschau für ausstehende Uploads + const fileInput = document.getElementById('health-file-input'); + const pendingBox = document.getElementById('health-file-pending'); + if (fileInput && pendingBox) { + fileInput.addEventListener('change', () => { + pendingBox.innerHTML = ''; + Array.from(fileInput.files || []).forEach(f => { + const isPdf = f.name.toLowerCase().endsWith('.pdf'); + const thumb = document.createElement('div'); + thumb.className = 'health-media-thumb health-media-thumb--pending'; + if (isPdf) { + thumb.innerHTML = `
PDF
${_esc(f.name.slice(0, 18))}`; + } else { + const img = document.createElement('img'); + img.src = URL.createObjectURL(f); + thumb.appendChild(img); + } + pendingBox.appendChild(thumb); + }); + }); + } + // X-Buttons für vorhandene Media-Items + document.querySelectorAll('#health-media-grid .health-media-remove').forEach(btn => { + btn.addEventListener('click', async () => { + const mediaId = parseInt(btn.dataset.mediaId); + const dogId = _appState.activeDog.id; + if (!mediaId || !entry?.id) return; + try { + await API.health.deleteMedia(dogId, entry.id, mediaId); + // Aus entry.media_items entfernen + if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId); + btn.closest('.health-media-thumb').remove(); + // Auch in _data aktualisieren + const list = _data[t] || []; + const idx = list.findIndex(x => x.id === entry.id); + if (idx !== -1) list[idx].media_items = (list[idx].media_items || []).filter(m => m.id !== mediaId); + UI.toast.success('Datei entfernt.'); + } catch (err) { + UI.toast.error('Fehler beim Löschen.'); + } + }); + }); }, 150); document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close); @@ -1070,16 +1166,26 @@ window.Page_health = (() => { UI.toast.success('Eintrag erstellt.'); } - // Datei-Upload für Dokumente - if (t === 'dokument' && form.querySelector('[name="datei"]')?.files[0]) { - try { - const formData = new FormData(); - formData.append('file', form.querySelector('[name="datei"]').files[0]); - const res = await API.health.uploadDokument(_appState.activeDog.id, saved.id, formData); - saved.datei_url = res.datei_url; - saved.datei_typ = res.datei_typ; - } catch { - UI.toast.warning('Eintrag erstellt, Datei konnte nicht hochgeladen werden.'); + // Multi-File-Upload + const fileInput = form.querySelector('[name="datei_neu"]'); + const files = fileInput ? Array.from(fileInput.files || []) : []; + if (files.length) { + const dogId = _appState.activeDog.id; + if (!saved.media_items) saved.media_items = []; + for (const f of files) { + try { + const fd = new FormData(); + fd.append('file', f); + const res = await API.health.uploadMedia(dogId, saved.id, fd); + saved.media_items.push({ id: res.id, url: res.url, media_type: res.media_type }); + // Rückwärtskompatibilität: erste Datei auch als datei_url sichern + if (!saved.datei_url) { + saved.datei_url = res.url; + saved.datei_typ = res.media_type; + } + } catch { + UI.toast.warning(`Datei "${f.name}" konnte nicht hochgeladen werden.`); + } } } diff --git a/backend/static/sw.js b/backend/static/sw.js index 8c76a2a..7242f59 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-v212'; +const CACHE_VERSION = 'by-v213'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten