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

@ -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);
}

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 };
})();