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:
parent
70f5eedafa
commit
44b1451966
3 changed files with 710 additions and 3 deletions
|
|
@ -1,3 +1,162 @@
|
||||||
"""BAN YARO — Tagebuch Routes (Stub, wird in Sprint 1 ausgebaut)"""
|
"""BAN YARO — Tagebuch Routes"""
|
||||||
from fastapi import APIRouter
|
|
||||||
router = 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()
|
||||||
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
418
backend/static/js/pages/diary.js
Normal file
418
backend/static/js/pages/diary.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// PUBLIC
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return { init, refresh, openNew, onDogChange };
|
||||||
|
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue