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 += `${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}
` + : ''; + + return ` +${_escape(entry.text)}
` + : ''} + ${tags.length + ? `