banyaro/backend/static/js/pages/diary.js
rene 5141ba9969 Session 2026-04-20: Medien-Konvertierung, Umami Analytics, Username/Privacy
- HEIC→JPEG, MOV/AVI→MP4 Konvertierung bei allen Upload-Endpoints (media_utils.py)
- ffmpeg im Docker-Image, Video-Thumbnails (extract_video_thumb, poster-Attribut)
- Google Analytics entfernt, Umami self-hosted eingebunden (index.html, datenschutz.js)
- Admin-Panel Analytics-Tab: Stat-Cards, Sparkline 7 Tage, Top-Seiten (Umami-API-Proxy)
- Admin-Panel Tab-Icons korrigiert (aus vorhandenem Phosphor-Sprite)
- users.real_name Spalte: Username öffentlich, echter Name privat und optional
- Registrierung: Label "Benutzername", Leerzeichen verboten, Profanity-Blockliste
- Datenschutzerklärung: GA-Abschnitt durch Umami-Text ersetzt
2026-04-20 18:36:58 +02:00

1263 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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;
let _searchQuery = '';
let _filterMilestone = false;
const LIMIT = 20;
function _sourceIcon(source) {
if (source === 'places') return 'star';
if (source === 'osm') return 'map-pin';
return 'map-trifold';
}
const _VIDEO_EXT = new Set(['.mp4','.mov','.webm','.m4v','.avi']);
function _isVideo(url) {
if (!url) return false;
return _VIDEO_EXT.has(url.slice(url.lastIndexOf('.')).toLowerCase());
}
function _videoPoster(url) { return url.replace(/\.[^.]+$/, '_thumb.jpg'); }
function _mediaHtml(url, style = '') {
if (!url) return '';
return _isVideo(url)
? `<video src="${url}" poster="${_videoPoster(url)}" controls playsinline style="width:100%;border-radius:var(--radius-md);${style}"></video>`
: `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`;
}
/** Alle Mediendateien eines Eintrags normalisiert als Array zurückgeben.
* Rückwärtskompatibel: wenn media_items leer, aber media_url gesetzt → altes Format. */
function _allMedia(entry) {
const items = entry.media_items || [];
if (items.length > 0) return items;
if (entry.media_url) {
return [{ id: null, url: entry.media_url,
media_type: _isVideo(entry.media_url) ? 'video' : 'image', sort_order: 0 }];
}
return [];
}
const TYPEN = {
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' },
meilenstein:{ label: 'Meilenstein',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>' },
training: { label: 'Training', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg>' },
gesundheit: { label: 'Gesundheit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
spaziergang:{ label: 'Spaziergang', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>' },
ausflug: { label: 'Ausflug', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>' },
};
// ----------------------------------------------------------
// INIT — erster Aufruf, Container leer
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
UI.loadLeaflet(); // Leaflet im Hintergrund vorladen — kein await
await _render();
}
// ----------------------------------------------------------
// REFRESH — erneuter Navigations-Aufruf (Tap auf Tab)
// ----------------------------------------------------------
async function refresh() {
if (!_appState.activeDog) return;
// Mehrere Hunde → Picker zeigen (User kann Hund wählen)
if (_appState.dogs.length > 1) {
_renderDogPicker();
return;
}
// Einzelner Hund → Diary direkt neu laden
_offset = 0;
_entries = [];
await _renderDiary();
}
// ----------------------------------------------------------
// ON DOG CHANGE — vom Header-Switcher ausgelöst
// ----------------------------------------------------------
async function onDogChange(dog) {
_offset = 0;
_entries = [];
_searchQuery = '';
await _renderDiary();
}
// ----------------------------------------------------------
// OPEN NEW — vom + Button oder Quick-Add
// ----------------------------------------------------------
function openNew() {
_showForm(null);
}
// ----------------------------------------------------------
// RENDER — Einstieg: Picker bei mehreren Hunden, sonst direkt
// ----------------------------------------------------------
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
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;
}
if (_appState.dogs.length > 1) {
_renderDogPicker();
} else {
await _renderDiary();
}
}
// ----------------------------------------------------------
// HUNDE-PICKER — Einstiegsseite bei mehreren Hunden
// ----------------------------------------------------------
function _renderDogPicker() {
const activeDogId = _appState.activeDog?.id;
const cards = _appState.dogs.map(dog => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}">`
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
<div class="diary-picker-av">${av}</div>
<div class="diary-picker-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${UI.escape(dog.rasse)}</div>` : ''}
</div>`;
}).join('');
_container.innerHTML = `
<div class="diary-picker-wrap">
<p class="diary-picker-hint">Wessen Tagebuch?</p>
<div class="diary-picker-grid">${cards}</div>
</div>`;
_container.querySelectorAll('.diary-picker-card').forEach(el => {
el.addEventListener('click', async () => {
const id = parseInt(el.dataset.dogId);
if (id === _appState.activeDog?.id) {
// Bereits aktiver Hund → direkt Diary laden
_offset = 0; _entries = [];
await _renderDiary();
} else {
App.setActiveDog(id);
// onDogChange() → _renderDiary() via _notifyDogChange()
}
});
});
}
// ----------------------------------------------------------
// DIARY-ANSICHT — Timeline mit Einträgen
// ----------------------------------------------------------
async function _renderDiary() {
_container.innerHTML = `
<div class="by-toolbar diary-toolbar">
<div class="diary-search-wrap" id="diary-search-wrap">
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" class="diary-search-input" id="diary-search-input"
placeholder="Einträge durchsuchen…" autocomplete="off">
</div>
<button class="btn btn-secondary btn-sm${_filterMilestone ? ' btn-active' : ''}" id="diary-milestone-filter" title="Nur Meilensteine">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
</button>
<button class="btn btn-secondary btn-sm" id="diary-import-btn" title="Importieren">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
</button>
</div>
<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-milestone-filter')
?.addEventListener('click', async () => {
_filterMilestone = !_filterMilestone;
_offset = 0; _entries = [];
const btn = _container.querySelector('#diary-milestone-filter');
btn?.classList.toggle('btn-active', _filterMilestone);
await _load();
_renderList();
});
_container.querySelector('#diary-import-btn')
?.addEventListener('click', _showImport);
_container.querySelector('#diary-btn-more')
?.addEventListener('click', () => _loadMore());
// Suche mit Debounce
let _searchTimer = null;
_container.querySelector('#diary-search-input')
?.addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(async () => {
_offset = 0;
_entries = [];
_searchQuery = e.target.value.trim();
await _load();
_renderList();
}, 350);
});
await _load();
_renderList();
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _load() {
const dog = _appState.activeDog;
if (!dog) return;
try {
const params = { limit: LIMIT, offset: _offset };
if (_searchQuery) params.q = _searchQuery;
if (_filterMilestone) params.milestone = 1;
const batch = await API.diary.list(dog.id, params);
_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: UI.icon('book-open'),
title: 'Noch keine Tagebucheinträge',
text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</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 || []).filter(t => t && t.trim()).slice(0, 4);
const allMedia = _allMedia(e);
const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null;
const mediaCount = allMedia.length;
const photo = coverMedia
? `<div class="diary-card-photo">
${coverMedia.media_type === 'video'
? `<div class="diary-card-video-thumb"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg></div>`
: `<img src="${e.cover_url || coverMedia.url}" alt="Foto" loading="lazy">`}
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`
: '';
const tagsHtml = tags.length
? `<div class="diary-card-tags">${tags.map(t => `<span class="badge">${t}</span>`).join('')}</div>`
: '';
const locationHtml = e.location_name
? `<p class="diary-card-location"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${UI.escape(e.location_name)}</p>`
: '';
const textPreview = e.text
? `<p class="diary-card-text">${UI.escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
// Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein')
const milestoneBadge = e.is_milestone
? `<div class="diary-card-milestone-badge">${UI.icon('calendar-dots')} Meilenstein</div>`
: '';
// Mehrere Hunde: kleine Avatare in der Karte
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
return `
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo}
<div class="diary-card-body">
${milestoneBadge}
<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">${UI.escape(e.titel)}</div>` : ''}
${locationHtml}
${textPreview}
${tagsHtml}
${dogAvatars}
</div>
</div>
`;
}
function _dogAvatarRow(dogIds) {
if (!dogIds || dogIds.length <= 1) return '';
const avatars = dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${UI.escape(dog.name)}">
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
}
// ----------------------------------------------------------
// LIGHTBOX
// ----------------------------------------------------------
// ----------------------------------------------------------
// LIGHTBOX — Fotos mit Vor/Zurück-Navigation
// ----------------------------------------------------------
function _showLightbox(urls, startIdx = 0) {
const photos = Array.isArray(urls) ? urls : [urls];
let idx = startIdx;
const lb = document.createElement('div');
lb.id = 'diary-lightbox';
lb.style.cssText = 'position:fixed;inset:0;z-index:1100;background:#000;display:flex;flex-direction:column';
const render = () => {
lb.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:calc(env(safe-area-inset-top,0px)+10px) 16px 10px;flex-shrink:0">
<button id="lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;
width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;
display:flex;align-items:center;justify-content:center">←</button>
${photos.length > 1
? `<span style="color:rgba(255,255,255,.7);font-size:13px">${idx+1} / ${photos.length}</span>`
: ''}
<div style="width:40px"></div>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<img src="${UI.escape(photos[idx])}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom;display:block">
${photos.length > 1 ? `
<button id="lb-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;border-radius:50%;width:44px;height:44px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center
${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}"></button>
<button id="lb-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;border-radius:50%;width:44px;height:44px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center
${idx === photos.length-1 ? ';opacity:.3;pointer-events:none' : ''}"></button>
` : ''}
</div>
`;
lb.querySelector('#lb-close').addEventListener('click', () => lb.remove());
lb.querySelector('#lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } });
lb.querySelector('#lb-next')?.addEventListener('click', () => { if (idx < photos.length-1) { idx++; render(); } });
};
render();
document.body.appendChild(lb);
}
// ----------------------------------------------------------
// DETAIL-ANSICHT — Fullscreen (DayOne-Stil)
// ----------------------------------------------------------
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 || []).filter(t => t && t.trim());
const allMedia = _allMedia(entry);
const dogIds = entry.dog_ids || [entry.dog_id];
const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs" style="margin-bottom:var(--space-3)">
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div><span>${UI.escape(dog.name)}</span></div>` : '';
}).join('')}
</div>` : '';
const view = document.createElement('div');
view.id = 'diary-detail-view';
view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column';
// Medien-HTML für Hero-Bereich
const _heroHtml = (m) => m.media_type === 'pdf'
? `<a href="${UI.escape(m.url)}" target="_blank" rel="noopener"
style="display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:12px;padding:32px 16px;background:var(--c-surface-2);text-decoration:none;color:var(--c-text)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>
<span style="font-size:14px;font-weight:600">${UI.escape(m.url.split('/').pop())}</span>
<span style="font-size:12px;color:var(--c-text-secondary)">PDF öffnen</span>
</a>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
: `<img src="${UI.escape(m.url)}" data-idx="${allMedia.indexOf(m)}" style="width:100%;max-height:55vh;object-fit:cover;display:block;cursor:zoom-in">`;
let mediaSection = '';
if (allMedia.length === 1) {
mediaSection = `<div style="background:#000;flex-shrink:0" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>`;
} else if (allMedia.length > 1) {
mediaSection = `
<div style="background:#000;flex-shrink:0" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>
<div style="display:flex;gap:3px;padding:3px;overflow-x:auto;background:#111;flex-shrink:0" id="diary-dv-thumbs">
${allMedia.map((m, i) => `
<div data-idx="${i}" style="flex-shrink:0;width:64px;height:64px;border-radius:4px;overflow:hidden;
cursor:pointer;border:2px solid ${i===0?'var(--c-primary)':'transparent'};box-sizing:border-box">
${m.media_type === 'pdf'
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)"><svg class="ph-icon" style="width:28px;height:28px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
: `<img src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover">`}
</div>`).join('')}
</div>`;
}
view.innerHTML = `
<div style="position:sticky;top:0;z-index:10;display:flex;align-items:center;
justify-content:space-between;
padding:calc(env(safe-area-inset-top,0px) + 8px) 16px 8px;
background:var(--c-surface);border-bottom:1px solid var(--c-border);flex-shrink:0">
<button id="diary-dv-back" style="display:flex;align-items:center;gap:6px;background:none;
border:none;color:var(--c-primary);font-size:16px;cursor:pointer;padding:4px 0">
← Zurück
</button>
${!_appState?.activeDog?.is_guest
? `<button id="diary-dv-edit" style="background:none;border:none;color:var(--c-primary);
font-size:14px;cursor:pointer;padding:4px 0">Bearbeiten</button>`
: '<div></div>'}
</div>
${mediaSection}
<div style="padding:var(--space-4);flex:1">
${isMile ? `<div class="diary-detail-milestone-badge" style="margin-bottom:var(--space-3)">${UI.icon('trophy')} Meilenstein</div>` : ''}
${entry.titel ? `<h2 style="margin:0 0 var(--space-2);font-size:1.3rem;font-weight:700;color:var(--c-text)">${UI.escape(entry.titel)}</h2>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap">
<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.location_name ? `
<div class="diary-detail-location" style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat
? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}"
target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>`
: UI.escape(entry.location_name)}
</div>` : ''}
${dogsHtml}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.7;color:var(--c-text);margin:0 0 var(--space-4)">${UI.escape(_cleanText(entry.text))}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
</div>
`;
document.body.appendChild(view);
// Zurück
view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove());
// Bearbeiten
view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => {
view.remove();
if (entry.location_name !== undefined || entry.gps_lat !== undefined) {
_showForm(entry);
} else {
try {
const fresh = await API.diary.get(_appState.activeDog.id, entry.id);
const idx = _entries.findIndex(e => e.id === entry.id);
if (idx !== -1) _entries[idx] = fresh;
_showForm(fresh);
} catch { _showForm(entry); }
}
});
// Foto in Hero → Lightbox
const photoUrls = allMedia.filter(m => m.media_type !== 'video').map(m => m.url);
view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', e => {
const clickedIdx = parseInt(e.target.dataset.idx ?? 0);
const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1;
_showLightbox(photoUrls, Math.max(0, photoIdx));
});
// Thumbnail-Strip → Hero wechseln
view.querySelector('#diary-dv-thumbs')?.addEventListener('click', e => {
const thumb = e.target.closest('[data-idx]');
if (!thumb) return;
const i = parseInt(thumb.dataset.idx);
const hero = view.querySelector('#diary-dv-hero');
if (hero) hero.innerHTML = _heroHtml(allMedia[i]);
// Foto in neuem Hero → Lightbox
hero?.querySelector('img')?.addEventListener('click', ev => {
const clickedIdx = parseInt(ev.target.dataset.idx ?? i);
const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1;
_showLightbox(photoUrls, Math.max(0, photoIdx));
});
// Aktive Markierung
view.querySelectorAll('#diary-dv-thumbs [data-idx]').forEach((t, j) => {
t.style.borderColor = j === i ? 'var(--c-primary)' : 'transparent';
});
});
// Cover-Button: Stern-Icon auf aktiven Medien (optional, nur für eingeloggte)
if (!_appState?.activeDog?.is_guest && allMedia.some(m => m.id)) {
// Cover-Verwaltung über Edit-Dialog
}
}
// ----------------------------------------------------------
// FORMULAR — Neu erstellen / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
const isEdit = !!entry;
const today = new Date().toISOString().slice(0, 10);
const activeDog = _appState.activeDog;
const typOpts = Object.entries(TYPEN)
.map(([val, { icon, label }]) =>
`<option value="${val}" ${entry?.typ === val ? 'selected' : ''}>${icon} ${label}</option>`)
.join('');
// Weitere Hunde: alle außer dem aktiven
const otherDogs = _appState.dogs.filter(d => d.id !== activeDog?.id);
const entryDogIds = entry?.dog_ids || [activeDog?.id];
const dogPickerHtml = otherDogs.length > 0 ? `
<div class="form-group">
<label class="form-label">Betrifft auch</label>
<div class="diary-dog-picker">
${otherDogs.map(d => `
<label class="diary-dog-pick-item ${entryDogIds.includes(d.id) ? 'checked' : ''}">
<input type="checkbox" name="extra_dog" value="${d.id}"
${entryDogIds.includes(d.id) ? 'checked' : ''}>
<div class="diary-dog-av">
${d.foto_url ? `<img src="${UI.escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${UI.escape(d.name)}</span>
</label>`).join('')}
</div>
</div>` : '';
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="${UI.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…">${UI.escape(entry?.text || '')}</textarea>
</div>
<div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
<div style="position:relative">
<div id="diary-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:220px;background:var(--c-surface-2)"></div>
<button type="button" id="diary-map-edit-btn" class="btn btn-secondary btn-sm"
style="position:absolute;bottom:var(--space-2);right:var(--space-2);z-index:500">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
<span id="diary-map-edit-label">Position ändern</span>
</button>
</div>
<!-- POI-Name + Aktionen -->
<div style="margin-top:var(--space-2)">
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-label">${UI.escape(entry?.location_name || '')}</span>
<button type="button" id="diary-location-clear" aria-label="Name entfernen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary btn-sm" id="diary-location-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-btn-label">POI suchen</span>
</button>
</div>
<div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
</div>
</div>
${dogPickerHtml}
<div class="form-group" style="margin-top:var(--space-5)">
<input type="checkbox" name="is_milestone" id="diary-milestone-cb"
${entry?.is_milestone ? 'checked' : ''} style="display:none">
<button type="button" id="diary-milestone-btn"
class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
<span>${entry?.is_milestone ? 'Meilenstein ✓' : 'Als Meilenstein markieren'}</span>
</button>
</div>
<div class="form-group">
<label class="form-label">Fotos / Videos <span style="color:var(--c-text-secondary)">(optional)</span></label>
<!-- Bestehende Medien (Edit-Modus) -->
<div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckter Input — multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<!-- Einzelner Button — iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="margin-top:var(--space-2);cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg>
Fotos / Videos hinzufügen
</label>
</div>
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="diary-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
<div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="diary-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
</div>
</div>
`;
UI.modal.open({ title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag', body, footer });
const form = document.getElementById('diary-form');
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// ---- Multi-Media-Verwaltung ----
const mediaInput = document.getElementById('diary-media-input');
// Neue Dateien die noch nicht hochgeladen wurden
const _newFiles = [];
function _renderNewGrid() {
const grid = document.getElementById('diary-new-media-grid');
if (!grid) return;
if (_newFiles.length === 0) { grid.style.display = 'none'; grid.innerHTML = ''; return; }
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px';
grid.innerHTML = _newFiles.map((f, i) => {
const objUrl = URL.createObjectURL(f);
const thumb = f.type === 'application/pdf' || f.name?.endsWith('.pdf')
? `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;gap:4px;padding:8px;text-align:center">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>
<div style="font-size:10px;color:var(--c-text-secondary);word-break:break-all;line-height:1.2">${f.name}</div>
</div>`
: f.type.startsWith('video/')
? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${objUrl}" alt="" class="diary-media-thumb">`;
return `<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)" data-new-idx="${i}">
${thumb}
<button type="button" class="diary-media-thumb-del" data-new-idx="${i}"
aria-label="Entfernen"
style="position:absolute;top:4px;right:4px;width:24px;height:24px;border-radius:50%;border:none;background:rgba(0,0,0,.55);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px">✕</button>
</div>`;
}).join('');
grid.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.newIdx);
_newFiles.splice(idx, 1);
_renderNewGrid();
});
});
}
// Bestehende Medien im Edit-Modus rendern
function _renderExistingMedia() {
const wrap = document.getElementById('diary-existing-media');
if (!wrap) return;
const items = isEdit ? _allMedia(entry) : [];
if (items.length === 0) { wrap.innerHTML = ''; return; }
const GRID_STYLE = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px';
const grid = `<div style="${GRID_STYLE}">
${items.map((m, idx) => `
<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)"
data-media-id="${m.id ?? ''}">
${m.media_type === 'video'
? `<video src="${m.url}" poster="${_videoPoster(m.url)}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
: `<img src="${m.url}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`}
<button type="button" class="diary-media-thumb-del"
data-media-id="${m.id ?? ''}" data-legacy="${m.id == null ? '1' : ''}"
aria-label="Entfernen"
style="position:absolute;top:4px;right:4px;width:24px;height:24px;border-radius:50%;border:none;background:rgba(0,0,0,.55);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px">✕</button>
${m.id != null ? `
<button type="button" class="diary-cover-btn diary-cover-btn--form${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}" data-sort="${idx}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
style="position:absolute;bottom:4px;left:4px;width:28px;height:28px;border-radius:50%;border:none;background:${m.is_cover ? '#f5c518' : 'rgba(0,0,0,.45)'};color:${m.is_cover ? '#fff' : 'rgba(255,255,255,.7)'};cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:2"><svg style="width:16px;height:16px;display:block;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg></button>` : ''}
</div>`).join('')}
</div>`;
wrap.innerHTML = grid;
wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', async () => {
const wrap2 = btn.closest('.diary-media-thumb-wrap');
const mediaId = btn.dataset.mediaId ? parseInt(btn.dataset.mediaId) : null;
const isLegacy = !!btn.dataset.legacy;
btn.disabled = true;
try {
if (mediaId != null) {
await API.diary.deleteMediaItem(_appState.activeDog.id, entry.id, mediaId);
// aus entry.media_items entfernen
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
} else if (isLegacy) {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
}
wrap2.remove();
UI.toast.success('Medium entfernt.');
} catch (e) {
btn.disabled = false;
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
});
// Stern-Buttons im Edit-Formular
wrap.querySelectorAll('.diary-cover-btn--form').forEach(btn => {
btn.addEventListener('click', async () => {
const mediaId = parseInt(btn.dataset.mediaId);
try {
await API.diary.setCover(_appState.activeDog.id, entry.id, mediaId);
if (entry.media_items) {
entry.media_items.forEach(m => { m.is_cover = m.id === mediaId ? 1 : 0; });
}
entry.cover_url = (entry.media_items || []).find(m => m.id === mediaId)?.url || null;
_updateEntryInList(entry);
// Alle Sterne in diesem Formular aktualisieren
wrap.querySelectorAll('.diary-cover-btn--form').forEach(b => {
const active = parseInt(b.dataset.mediaId) === mediaId;
b.classList.toggle('diary-cover-btn--active', active);
b.style.background = active ? '#f5c518' : 'rgba(0,0,0,.45)';
b.style.color = active ? '#fff' : 'rgba(255,255,255,.7)';
b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen');
b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen');
const use = b.querySelector('use');
if (use) use.setAttribute('href', `/icons/phosphor.svg#${active ? 'star-fill' : 'star'}`);
});
UI.toast.success('Cover-Bild gesetzt.');
} catch {
UI.toast.error('Cover konnte nicht gesetzt werden.');
}
});
});
}
_renderExistingMedia();
function _addFiles(fileList) {
for (const f of fileList) _newFiles.push(f);
_renderNewGrid();
}
function _openPicker(opts = {}) {
const tmp = document.createElement('input');
tmp.type = 'file'; tmp.accept = 'image/*,video/*'; tmp.style.display = 'none';
if (opts.capture) tmp.setAttribute('capture', opts.capture);
if (opts.noAccept) tmp.removeAttribute('accept');
tmp.addEventListener('change', () => {
_addFiles(tmp.files);
tmp.remove();
});
document.body.appendChild(tmp);
tmp.click();
}
mediaInput?.addEventListener('change', () => {
if (mediaInput.files.length) {
_addFiles(mediaInput.files);
mediaInput.value = '';
}
});
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Milestone-Toggle
document.getElementById('diary-milestone-btn')?.addEventListener('click', () => {
const cb = document.getElementById('diary-milestone-cb');
const btn = document.getElementById('diary-milestone-btn');
cb.checked = !cb.checked;
btn.classList.toggle('diary-milestone-toggle--active', cb.checked);
btn.querySelector('span').textContent = cb.checked ? 'Meilenstein ✓' : 'Als Meilenstein markieren';
});
// --- Location Picker ---
let _locLat = (entry?.gps_lat != null) ? entry.gps_lat : null;
let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null;
let _locName = entry?.location_name || null;
let _miniMap = null, _miniMarker = null;
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] });
function _setName(name) {
_locName = name;
document.getElementById('diary-location-label').textContent = name;
document.getElementById('diary-location-chip-wrap').style.display = '';
document.getElementById('diary-location-suggestions').style.display = 'none';
}
function _placeMarker(lat, lon) {
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
_miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
_miniMarker.on('dragend', () => {
const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng;
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
});
}
document.getElementById('diary-location-clear')?.addEventListener('click', () => {
_locName = null;
document.getElementById('diary-location-chip-wrap').style.display = 'none';
});
const _clearBtn = document.getElementById('diary-coords-clear');
let _clearPending = false;
_clearBtn?.addEventListener('click', () => {
if (!_clearPending) {
_clearPending = true;
_clearBtn.textContent = 'Wirklich entfernen?';
_clearBtn.style.color = 'var(--c-danger)';
setTimeout(() => {
if (_clearPending) {
_clearPending = false;
_clearBtn.textContent = 'Ort entfernen';
_clearBtn.style.color = 'var(--c-text-muted)';
}
}, 3000);
return;
}
_clearPending = false;
_clearBtn.textContent = 'Ort entfernen';
_clearBtn.style.color = 'var(--c-text-muted)';
_locLat = null; _locLon = null; _locName = null;
document.getElementById('diary-location-chip-wrap').style.display = 'none';
document.getElementById('diary-location-suggestions').style.display = 'none';
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); }
});
let _mapEditing = false;
function _setMapEditing(on) {
_mapEditing = on;
const lbl = document.getElementById('diary-map-edit-label');
if (lbl) lbl.textContent = on ? 'Fertig' : 'Position ändern';
if (!_miniMap) return;
if (on) {
if (_miniMarker) _miniMarker.dragging.enable();
} else {
if (_miniMarker) _miniMarker.dragging.disable();
}
}
document.getElementById('diary-map-edit-btn')?.addEventListener('click', () => {
_setMapEditing(!_mapEditing);
});
// Karte beim Formular-Open automatisch laden
UI.loadLeaflet().then(() => {
setTimeout(() => {
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
_miniMap = L.map('diary-map-wrap', {
zoomControl: true, attributionControl: false,
dragging: true, scrollWheelZoom: false,
}).setView([lat, lon], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_miniMap);
_miniMap.invalidateSize();
if (_locLat) {
_placeMarker(lat, lon);
_miniMarker.dragging.disable(); // Lesemodus: kein Drag
}
// Klick nur im Edit-Modus
_miniMap.on('click', e => {
if (!_mapEditing) return;
_locLat = e.latlng.lat; _locLon = e.latlng.lng;
_placeMarker(_locLat, _locLon);
if (!_mapEditing) _miniMarker.dragging.disable();
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
});
}, 150);
});
async function _showSuggestions() {
const btn = document.getElementById('diary-location-btn');
UI.setLoading(btn, true);
try {
let lat = _locLat, lon = _locLon;
if (lat == null || lon == null) {
const pos = await API.getLocation();
lat = pos.lat; lon = pos.lon;
_locLat = lat; _locLon = lon;
if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); }
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
}
const suggestions = await API.diary.nearby(_appState.activeDog.id, lat, lon);
const sugEl = document.getElementById('diary-location-suggestions');
if (suggestions.length === 0) {
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_sourceIcon(s.source)}"></use></svg>
<span>${UI.escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
el.addEventListener('click', () => _setName(el.dataset.name));
});
}
sugEl.style.display = '';
} catch (err) {
UI.toast.error(err?.message?.includes('GPS') || lat == null
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
} finally {
UI.setLoading(btn, false);
}
}
document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions);
document.getElementById('diary-form-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(entry.id);
});
// Checked-Klasse auf Dog-Picker-Items toggeln
form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.diary-dog-pick-item').classList.toggle('checked', cb.checked);
});
});
form.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="diary-form"][type="submit"]') || form.querySelector('[type="submit"]');
const fd = UI.formData(form);
// dog_ids zusammenbauen: aktiver Hund + gewählte weitere
const dogIds = [_appState.activeDog.id];
form.querySelectorAll('.diary-dog-pick-item input:checked').forEach(cb => {
const id = parseInt(cb.value);
if (!dogIds.includes(id)) dogIds.push(id);
});
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,
dog_ids: dogIds,
gps_lat: _locLat,
gps_lon: _locLon,
location_name: _locName,
};
async function _uploadNewFiles(entryId) {
let failCount = 0;
const uploaded = [];
for (const file of _newFiles) {
try {
const formData = new FormData();
formData.append('file', file);
const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData);
uploaded.push(m);
} catch {
failCount++;
}
}
if (failCount > 0) {
UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`);
}
return uploaded;
}
if (isEdit) {
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload);
if (_newFiles.length > 0) {
const uploaded = await _uploadNewFiles(entry.id);
if (!updated.media_items) updated.media_items = [];
updated.media_items.push(...uploaded);
} else {
// media_items aus dem aktuellen entry-State übernehmen (evtl. gelöscht via X-Button)
updated.media_items = entry.media_items || updated.media_items || [];
updated.media_url = entry.media_url ?? updated.media_url;
}
_updateEntryInList(updated);
UI.toast.success('Eintrag gespeichert.');
} else {
const created = await API.diary.create(_appState.activeDog.id, payload);
if (_newFiles.length > 0) {
const uploaded = await _uploadNewFiles(created.id);
created.media_items = uploaded;
}
_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));
}
// ----------------------------------------------------------
// IMPORT
// ----------------------------------------------------------
function _showImport() {
UI.modal.open({
title: 'Tagebuch importieren',
body: `
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-4)">
Importiere Einträge aus einer anderen App in das Tagebuch von
<strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<label class="import-format-card" id="fmt-nsx">
<input type="radio" name="import-fmt" value="nsx" checked style="display:none">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
</div>
</label>
<label class="import-format-card" id="fmt-csv">
<input type="radio" name="import-fmt" value="csv" style="display:none">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">CSV / Excel</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
</div>
</label>
</div>
<div style="margin-top:var(--space-4)">
<label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer">
</div>
<div id="import-result" style="display:none;margin-top:var(--space-4)"></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="import-start-btn">Importieren</button>`,
});
// Format-Karten klickbar machen
document.querySelectorAll('.import-format-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.import-format-card').forEach(c => c.classList.remove('import-format-card--active'));
card.classList.add('import-format-card--active');
card.querySelector('input[type=radio]').checked = true;
// Accept-Attribut anpassen
const fmt = card.querySelector('input').value;
document.getElementById('import-file-input').accept = fmt === 'nsx' ? '.nsx' : '.csv';
});
});
// Erste Karte direkt aktiv setzen
document.getElementById('fmt-nsx')?.classList.add('import-format-card--active');
document.getElementById('import-start-btn').addEventListener('click', async () => {
const fileInput = document.getElementById('import-file-input');
const fmt = document.querySelector('input[name="import-fmt"]:checked')?.value;
const btn = document.getElementById('import-start-btn');
const resultEl = document.getElementById('import-result');
if (!fileInput.files.length) {
UI.toast('Bitte zuerst eine Datei auswählen.', 'warning');
return;
}
const file = fileInput.files[0];
const dogId = _appState.activeDog?.id;
UI.setLoading(btn, true);
resultEl.style.display = 'none';
try {
const res = fmt === 'nsx'
? await API.importData.notestation(dogId, file)
: await API.importData.csv(dogId, file);
const errHtml = res.errors?.length
? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${UI.escape(res.errors.join('\n'))}</pre></details>`
: '';
resultEl.innerHTML = `
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)">
<strong>${res.imported} Einträge importiert</strong>
${res.skipped ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
${errHtml}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
// Diary neu laden falls etwas importiert wurde
if (res.imported > 0) {
_offset = 0;
_entries = [];
await _load();
_renderList();
}
} catch (e) {
resultEl.innerHTML = `
<div style="background:var(--c-danger-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-danger)">
Fehler: ${UI.escape(e.message || String(e))}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
}
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
function _cleanText(text) {
if (!text) return text;
return text
.replace(/!\[([^\]]*)\]\([^\)]*\)/g, '') // Markdown-Bilder ![]()
.replace(/\[([^\]]*)\]\([^\)]*\)/g, '$1') // Markdown-Links [text](url) → text
.replace(/^\[\]\s*$/gm, '') // leere [] auf eigener Zeile
.replace(/\n{3,}/g, '\n\n') // mehrfache Leerzeilen kürzen
.trim();
}
return { init, refresh, openNew, onDogChange, openDetail: _openDetail };
})();