Sprint 1: Tagebuch — Backend-Routes + Frontend-Modul

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)
This commit is contained in:
rene 2026-04-12 17:26:28 +02:00
parent 70f5eedafa
commit 44b1451966
3 changed files with 710 additions and 3 deletions

View file

@ -1,3 +1,162 @@
"""BAN YARO — Tagebuch Routes (Stub, wird in Sprint 1 ausgebaut)""" """BAN YARO — Tagebuch Routes"""
from fastapi import APIRouter
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() 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}

View file

@ -679,3 +679,133 @@ textarea.form-control {
.fab:hover { transform: scale(1.05); } .fab:hover { transform: scale(1.05); }
.fab:active { transform: scale(0.95); } .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);
}

View file

@ -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: `<button class="btn btn-primary" id="diary-goto-profile">Profil erstellen</button>`,
});
_container.querySelector('#diary-goto-profile')
?.addEventListener('click', () => App.navigate('dog-profile'));
return;
}
_container.innerHTML = `
<div id="diary-list"></div>
<div id="diary-load-more" style="display:none; text-align:center; padding:var(--space-4)">
<button class="btn btn-secondary" id="diary-btn-more">Weitere laden</button>
</div>
`;
_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: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
});
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 += `<div class="diary-month-header">${monthLabel}</div>`;
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
? `<div class="diary-card-photo">
<img src="${e.media_url}" alt="Foto" loading="lazy">
</div>`
: '';
const tagsHtml = tags.length
? `<div class="diary-card-tags">${tags.map(t => `<span class="badge">${t}</span>`).join('')}</div>`
: '';
const textPreview = e.text
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
return `
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo}
<div class="diary-card-body">
<div class="diary-card-meta">
<span class="diary-card-type">${typ.icon} ${typ.label}</span>
<span class="diary-card-date">${dateStr}</span>
</div>
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
${textPreview}
${tagsHtml}
</div>
</div>
`;
}
// ----------------------------------------------------------
// 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
? `<img src="${entry.media_url}" alt="Foto"
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: '';
const body = `
${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''}
${photo}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span>
</div>
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-3)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)">
<button class="btn btn-secondary flex-1" id="detail-edit">Bearbeiten</button>
<button class="btn btn-danger flex-1" id="detail-delete">Löschen</button>
</div>
`;
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 }]) =>
`<option value="${val}" ${entry?.typ === val ? 'selected' : ''}>${icon} ${label}</option>`)
.join('');
const body = `
<form id="diary-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Typ</label>
<select class="form-control" name="typ">${typOpts}</select>
</div>
<div class="form-group">
<label class="form-label">Datum</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
<div class="form-group">
<label class="form-label">Titel <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="titel"
value="${_escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div>
<div class="form-group">
<label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_milestone" ${entry?.is_milestone ? 'checked' : ''}>
Als Meilenstein markieren
</label>
</div>
${!isEdit ? `
<div class="form-group">
<label class="form-label">Foto <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="file" name="photo" accept="image/*">
<img id="diary-photo-preview" style="display:none;width:100%;max-height:200px;
object-fit:cover;border-radius:var(--radius-md);margin-top:var(--space-2)">
</div>
` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew, onDogChange };
})();