From 44b1451966d74709e8fe8b40868860328e14bb16 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 12 Apr 2026 17:26:28 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=201:=20Tagebuch=20=E2=80=94=20Backend-Ro?= =?UTF-8?q?utes=20+=20Frontend-Modul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diary.py: CRUD, KI-Auto-Tags, Medien-Upload, Ownership-Check diary.js: Timeline (nach Monat gruppiert), Erstellen/Bearbeiten/Löschen, Foto-Upload, Meilenstein-Hervorhebung, Tags, Detail-Modal components.css: Diary-Card-Styles (Timeline, Milestone, Foto, Tags) --- backend/routes/diary.py | 165 +++++++++++- backend/static/css/components.css | 130 ++++++++++ backend/static/js/pages/diary.js | 418 ++++++++++++++++++++++++++++++ 3 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 backend/static/js/pages/diary.js diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 069f740..56f348d 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,3 +1,162 @@ -"""BAN YARO — Tagebuch Routes (Stub, wird in Sprint 1 ausgebaut)""" -from fastapi import APIRouter -router = APIRouter() +"""BAN YARO — Tagebuch Routes""" + +import os, uuid, json +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user +import ki as KI + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + + +class DiaryCreate(BaseModel): + datum: Optional[str] = None # ISO date, default heute + typ: str = "eintrag" + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + gps_lat: Optional[float] = None + gps_lon: Optional[float] = None + is_milestone: bool = False + +class DiaryUpdate(BaseModel): + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + is_milestone: Optional[bool] = None + + +def _own_dog(dog_id: int, user_id: int, conn): + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + +@router.get("/{dog_id}/diary") +async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, + user=Depends(get_current_user)): + with db() as conn: + _own_dog(dog_id, user["id"], conn) + rows = conn.execute( + """SELECT * FROM diary WHERE dog_id=? + ORDER BY datum DESC, created_at DESC + LIMIT ? OFFSET ?""", + (dog_id, limit, offset) + ).fetchall() + entries = [] + for r in rows: + e = dict(r) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + entries.append(e) + return entries + + +@router.post("/{dog_id}/diary", status_code=201) +async def create_diary(dog_id: int, data: DiaryCreate, + user=Depends(get_current_user)): + tags = data.tags or [] + + # KI: Auto-Tags wenn Text vorhanden (lokal, kostenlos) + if data.text and len(data.text) > 10: + try: + ai_tags = await KI.diary_tags(data.text) + tags = list(set(tags + ai_tags)) + except Exception: + pass + + with db() as conn: + _own_dog(dog_id, user["id"], conn) + conn.execute( + """INSERT INTO diary + (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone) + VALUES (?, + COALESCE(?, date('now')), + ?,?,?,?,?,?,?)""", + (dog_id, data.datum, data.typ, data.titel, data.text, + json.dumps(tags), data.gps_lat, data.gps_lon, int(data.is_milestone)) + ) + entry = conn.execute( + "SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", + (dog_id,) + ).fetchone() + + e = dict(entry) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + return e + + +@router.get("/{dog_id}/diary/{entry_id}") +async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): + with db() as conn: + _own_dog(dog_id, user["id"], conn) + row = conn.execute( + "SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + e = dict(row) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + return e + + +@router.patch("/{dog_id}/diary/{entry_id}") +async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate, + user=Depends(get_current_user)): + with db() as conn: + _own_dog(dog_id, user["id"], conn) + fields = {k: v for k, v in data.model_dump().items() if v is not None} + if "tags" in fields: + fields["tags"] = json.dumps(fields["tags"]) + if not fields: + raise HTTPException(400, "Keine Änderungen.") + set_clause = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?", + list(fields.values()) + [entry_id, dog_id] + ) + row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() + e = dict(row) + e["tags"] = json.loads(e["tags"]) if e["tags"] else [] + return e + + +@router.delete("/{dog_id}/diary/{entry_id}", status_code=204) +async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): + with db() as conn: + _own_dog(dog_id, user["id"], conn) + conn.execute( + "DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) + ) + + +@router.post("/{dog_id}/diary/{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: + _own_dog(dog_id, user["id"], conn) + entry = conn.execute( + "SELECT id FROM diary 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] or ".jpg" + filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "diary", 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/diary/{filename}" + with db() as conn: + conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id)) + + return {"media_url": media_url} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 0ed44de..774dbec 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -679,3 +679,133 @@ textarea.form-control { .fab:hover { transform: scale(1.05); } .fab:active { transform: scale(0.95); } } + +/* ------------------------------------------------------------ + 12. TAGEBUCH + ------------------------------------------------------------ */ + +/* Monats-Trennlinie */ +.diary-month-header { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: var(--space-4) 0 var(--space-2); + border-bottom: 1px solid var(--c-border); + margin-bottom: var(--space-3); +} +.diary-month-header:first-child { + padding-top: 0; +} + +/* Eintragskarte */ +.diary-card { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + margin-bottom: var(--space-3); + overflow: hidden; + cursor: pointer; + transition: box-shadow var(--transition-fast), + transform var(--transition-fast); + box-shadow: var(--shadow-xs); + -webkit-tap-highlight-color: transparent; +} +.diary-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} +.diary-card:active { + transform: scale(0.99); +} + +/* Meilenstein-Hervorhebung */ +.diary-card--milestone { + border-color: var(--c-primary); + border-width: 2px; + background: linear-gradient( + 135deg, + var(--c-surface) 0%, + color-mix(in srgb, var(--c-primary) 6%, var(--c-surface)) 100% + ); +} + +/* Foto oben */ +.diary-card-photo { + width: 100%; + height: 180px; + overflow: hidden; +} +.diary-card-photo img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Card Body */ +.diary-card-body { + padding: var(--space-3) var(--space-4); +} + +/* Meta-Zeile: Typ + Datum */ +.diary-card-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-1); +} +.diary-card-type { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-primary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.diary-card-date { + font-size: var(--text-xs); + color: var(--c-text-secondary); +} + +/* Titel */ +.diary-card-title { + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--c-text); + margin-bottom: var(--space-1); +} + +/* Text-Vorschau */ +.diary-card-text { + font-size: var(--text-sm); + color: var(--c-text-secondary); + line-height: 1.5; + margin: 0 0 var(--space-2); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Tags */ +.diary-card-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-top: var(--space-1); +} + +/* Detail-Ansicht */ +.diary-detail-milestone-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + background: color-mix(in srgb, var(--c-primary) 12%, transparent); + color: var(--c-primary-dark); + font-weight: var(--weight-semibold); + font-size: var(--text-sm); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + margin-bottom: var(--space-3); +} diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js new file mode 100644 index 0000000..795241c --- /dev/null +++ b/backend/static/js/pages/diary.js @@ -0,0 +1,418 @@ +/* ============================================================ + BAN YARO — Tagebuch (Sprint 1) + Seiten-Modul: Timeline aller Einträge, Erstellen, Bearbeiten, + Löschen, Foto-Upload, Meilensteine. + ============================================================ */ + +window.Page_diary = (() => { + + // ---------------------------------------------------------- + // MODUL-STATE + // ---------------------------------------------------------- + let _container = null; + let _appState = null; + let _entries = []; + let _offset = 0; + const LIMIT = 20; + + const TYPEN = { + eintrag: { label: 'Eintrag', icon: '📖' }, + foto: { label: 'Foto', icon: '📷' }, + meilenstein:{ label: 'Meilenstein',icon: '🏆' }, + training: { label: 'Training', icon: '🎯' }, + gesundheit: { label: 'Gesundheit', icon: '💉' }, + ausflug: { label: 'Ausflug', icon: '🚗' }, + }; + + // ---------------------------------------------------------- + // INIT — erster Aufruf, Container leer + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + await _render(); + } + + // ---------------------------------------------------------- + // REFRESH — erneuter Navigations-Aufruf + // ---------------------------------------------------------- + async function refresh() { + if (!_appState.activeDog) return; + _offset = 0; + _entries = []; + await _load(); + _renderList(); + } + + // ---------------------------------------------------------- + // ON DOG CHANGE + // ---------------------------------------------------------- + async function onDogChange(dog) { + _offset = 0; + _entries = []; + await _render(); + } + + // ---------------------------------------------------------- + // OPEN NEW — vom + Button oder Quick-Add + // ---------------------------------------------------------- + function openNew() { + _showForm(null); + } + + // ---------------------------------------------------------- + // RENDER — Hauptstruktur + // ---------------------------------------------------------- + async function _render() { + if (!_appState.activeDog) { + _container.innerHTML = UI.emptyState({ + icon: '🐕', + title: 'Noch kein Hund angelegt', + text: 'Erstelle zuerst ein Hundeprofil, um das Tagebuch zu nutzen.', + action: ``, + }); + _container.querySelector('#diary-goto-profile') + ?.addEventListener('click', () => App.navigate('dog-profile')); + return; + } + + _container.innerHTML = ` +
+ + `; + + _container.querySelector('#diary-btn-more') + ?.addEventListener('click', () => _loadMore()); + + await _load(); + _renderList(); + } + + // ---------------------------------------------------------- + // DATEN LADEN + // ---------------------------------------------------------- + async function _load() { + const dog = _appState.activeDog; + if (!dog) return; + try { + const batch = await API.diary.list(dog.id, { limit: LIMIT, offset: _offset }); + _entries = _entries.concat(batch); + + // "Mehr laden" anzeigen wenn volle Page geladen wurde + const loadMore = _container.querySelector('#diary-load-more'); + if (loadMore) { + loadMore.style.display = batch.length === LIMIT ? 'block' : 'none'; + } + } catch (err) { + UI.toast.error('Einträge konnten nicht geladen werden.'); + } + } + + async function _loadMore() { + _offset += LIMIT; + const btn = _container.querySelector('#diary-btn-more'); + UI.setLoading(btn, true); + await _load(); + _renderList(); + UI.setLoading(btn, false); + } + + // ---------------------------------------------------------- + // LISTE RENDERN — Timeline gruppiert nach Monat + // ---------------------------------------------------------- + function _renderList() { + const listEl = _container.querySelector('#diary-list'); + if (!listEl) return; + + if (_entries.length === 0) { + listEl.innerHTML = UI.emptyState({ + icon: '📖', + title: 'Noch keine Einträge', + text: 'Halte besondere Momente mit deinem Hund fest.', + action: ``, + }); + listEl.querySelector('#diary-first-entry') + ?.addEventListener('click', () => _showForm(null)); + return; + } + + // Gruppieren nach Jahr-Monat (Anzeigereihenfolge: chronologisch absteigend) + const groups = new Map(); + _entries.forEach(e => { + const key = e.datum ? e.datum.slice(0, 7) : 'unbekannt'; // "2025-04" + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + }); + + let html = ''; + groups.forEach((items, key) => { + const monthLabel = key === 'unbekannt' ? 'Datum unbekannt' : _formatMonth(key); + html += `
${monthLabel}
`; + html += items.map(e => _entryCard(e)).join(''); + }); + + listEl.innerHTML = html; + + // Events an Karten binden + listEl.querySelectorAll('[data-entry-id]').forEach(card => { + const id = parseInt(card.dataset.entryId); + card.addEventListener('click', () => _openDetail(id)); + }); + } + + // ---------------------------------------------------------- + // ENTRY CARD + // ---------------------------------------------------------- + function _entryCard(e) { + const typ = TYPEN[e.typ] || TYPEN.eintrag; + const isMile = e.is_milestone || e.typ === 'meilenstein'; + const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; + const tags = (e.tags || []).slice(0, 4); + + const photo = e.media_url + ? `
+ Foto +
` + : ''; + + const tagsHtml = tags.length + ? `
${tags.map(t => `${t}`).join('')}
` + : ''; + + const textPreview = e.text + ? `

${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}

` + : ''; + + return ` +
+ ${photo} +
+
+ ${typ.icon} ${typ.label} + ${dateStr} +
+ ${e.titel ? `
${_escape(e.titel)}
` : ''} + ${textPreview} + ${tagsHtml} +
+
+ `; + } + + // ---------------------------------------------------------- + // DETAIL-ANSICHT + // ---------------------------------------------------------- + function _openDetail(entryId) { + const entry = _entries.find(e => e.id === entryId); + if (!entry) return; + + const typ = TYPEN[entry.typ] || TYPEN.eintrag; + const isMile = entry.is_milestone || entry.typ === 'meilenstein'; + const tags = (entry.tags || []); + + const photo = entry.media_url + ? `Foto` + : ''; + + const body = ` + ${isMile ? '
🏆 Meilenstein
' : ''} + ${photo} +
+ ${typ.icon} ${typ.label} + + ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} + +
+ ${entry.text + ? `

${_escape(entry.text)}

` + : ''} + ${tags.length + ? `
+ ${tags.map(t => `${t}`).join('')} +
` + : ''} +
+ + +
+ `; + + UI.modal.open({ title: entry.titel || typ.label, body }); + + document.getElementById('detail-edit')?.addEventListener('click', () => { + UI.modal.close(); + _showForm(entry); + }); + document.getElementById('detail-delete')?.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title: 'Eintrag löschen?', + message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.', + confirmText: 'Löschen', + danger: true, + }); + if (ok) { + await _deleteEntry(entryId); + } + }); + } + + // ---------------------------------------------------------- + // FORMULAR — Neu erstellen / Bearbeiten + // ---------------------------------------------------------- + function _showForm(entry) { + const isEdit = !!entry; + const today = new Date().toISOString().slice(0, 10); + const typOpts = Object.entries(TYPEN) + .map(([val, { icon, label }]) => + ``) + .join(''); + + const body = ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ ${!isEdit ? ` +
+ + + +
+ ` : ''} +
+ + +
+
+ `; + + UI.modal.open({ title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag', body }); + + const form = document.getElementById('diary-form'); + + // Foto-Vorschau + const photoInput = form.querySelector('[name="photo"]'); + const photoPreview = document.getElementById('diary-photo-preview'); + if (photoInput && photoPreview) { + UI.setupPhotoPreview(photoInput, photoPreview); + photoInput.addEventListener('change', () => { + photoPreview.style.display = photoInput.files[0] ? 'block' : 'none'; + }); + } + + document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close); + + form.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = form.querySelector('[type="submit"]'); + const fd = UI.formData(form); + + await UI.asyncButton(submitBtn, async () => { + const payload = { + datum: fd.datum || null, + typ: fd.typ, + titel: fd.titel || null, + text: fd.text || null, + is_milestone: 'is_milestone' in fd, + }; + + if (isEdit) { + const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload); + _updateEntryInList(updated); + UI.toast.success('Eintrag gespeichert.'); + } else { + const created = await API.diary.create(_appState.activeDog.id, payload); + + // Foto hochladen wenn vorhanden + if (photoInput?.files[0]) { + try { + const formData = new FormData(); + formData.append('file', photoInput.files[0]); + const media = await API.diary.uploadMedia( + _appState.activeDog.id, created.id, formData + ); + created.media_url = media.media_url; + } catch { + UI.toast.warning('Eintrag erstellt, Foto konnte nicht hochgeladen werden.'); + } + } + + _entries.unshift(created); + UI.toast.success('Eintrag erstellt.'); + } + + UI.modal.close(); + _renderList(); + }); + }); + } + + // ---------------------------------------------------------- + // EINTRAG LÖSCHEN + // ---------------------------------------------------------- + async function _deleteEntry(entryId) { + try { + await API.diary.delete(_appState.activeDog.id, entryId); + _entries = _entries.filter(e => e.id !== entryId); + UI.modal.close(); + _renderList(); + UI.toast.success('Eintrag gelöscht.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Löschen.'); + } + } + + // ---------------------------------------------------------- + // HELPER + // ---------------------------------------------------------- + function _updateEntryInList(updated) { + const i = _entries.findIndex(e => e.id === updated.id); + if (i !== -1) _entries[i] = updated; + } + + function _formatMonth(yearMonth) { + const [y, m] = yearMonth.split('-'); + return new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + .format(new Date(+y, +m - 1, 1)); + } + + function _escape(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, openNew, onDogChange }; + +})();