/* ============================================================
BAN YARO — Tagebuch (Sprint 1)
Seiten-Modul: Timeline aller Einträge, Erstellen, Bearbeiten,
Löschen, Foto-Upload, Meilensteine.
============================================================ */
window.Page_diary = (() => {
const _CACHE_KEY = 'by_diary_cache';
// ----------------------------------------------------------
// 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';
}
// ----------------------------------------------------------
// DATUM-HILFSFUNKTIONEN (Day One Style)
// ----------------------------------------------------------
const _WOCHENTAG = ['SO.', 'MO.', 'DI.', 'MI.', 'DO.', 'FR.', 'SA.'];
function _weekday(datum) {
if (!datum) return '';
return _WOCHENTAG[new Date(datum + 'T12:00').getDay()];
}
function _dayNum(datum) {
return datum ? String(parseInt(datum.slice(8, 10), 10)) : '';
}
function _timeStr(createdAt) {
if (!createdAt) return '';
const t = createdAt.includes('T') ? createdAt : createdAt.replace(' ', 'T');
const d = new Date(t);
return isNaN(d) ? '' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function _formatDateLong(datum) {
if (!datum) return '';
const d = new Date(datum + 'T12:00');
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
// WMO-Wettercodes → Emoji für Badges
const _WMO_EMOJI = {
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
45: '🌫️', 48: '🌫️',
51: '🌦️', 53: '🌦️', 55: '🌧️',
61: '🌦️', 63: '🌧️', 65: '🌧️',
71: '🌨️', 73: '❄️', 75: '❄️', 77: '🌨️',
80: '🌦️', 81: '🌧️', 82: '⛈️',
85: '🌨️', 86: '❄️',
95: '⛈️', 96: '⛈️', 99: '⛈️',
};
function _weatherEmoji(code, isDay) {
if (code === 0 && isDay === false) return '🌙';
return _WMO_EMOJI[code] || '🌡️';
}
/** Wetter-Badge HTML für Karten (kompakt) */
function _weatherBadgeHtml(entry) {
if (!entry.weather_json) return '';
let w;
try { w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json; }
catch (_) { return ''; }
if (!w || w.temp_c == null) return '';
const emoji = _weatherEmoji(w.weathercode, w.is_day);
const temp = Math.round(w.temp_c);
return `${emoji} ${temp}°`;
}
/** POI-Chips HTML (max. 3 Items) */
function _poiChipsHtml(entry, maxItems = 3) {
if (!entry.poi_json) return '';
let pois;
try { pois = typeof entry.poi_json === 'string' ? JSON.parse(entry.poi_json) : entry.poi_json; }
catch (_) { return ''; }
if (!Array.isArray(pois) || pois.length === 0) return '';
const _poiEmoji = (type) => {
if (!type) return '📍';
const t = type.toLowerCase();
if (t === 'veterinary' || t === 'hospital') return '🏥';
if (t === 'pet_shop' || t === 'shop') return '🛒';
if (t === 'park' || t === 'leisure' || t === 'garden') return '🌳';
if (t === 'restaurant' || t === 'cafe' || t === 'bar') return '☕';
if (t === 'historic' || t === 'monument') return '🏛️';
if (t === 'tourism' || t === 'attraction') return '🗺️';
if (t === 'playground') return '🛝';
return '📍';
};
const chips = pois.slice(0, maxItems)
.map(p => `${_poiEmoji(p.type)} ${UI.escape(p.name)}`)
.join(' · ');
return `
${chips}
`;
}
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)
? ``
: `
`;
}
/** 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: '' },
foto: { label: 'Foto', icon: '' },
meilenstein:{ label: 'Meilenstein',icon: '' },
training: { label: 'Training', icon: '' },
gesundheit: { label: 'Gesundheit', icon: '' },
spaziergang:{ label: 'Spaziergang', icon: '' },
ausflug: { label: 'Ausflug', icon: '' },
};
// ----------------------------------------------------------
// 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;
_offset = 0;
_entries = [];
_totalStats = null;
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: '',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil, um das Tagebuch zu nutzen.',
action: ``,
});
_container.querySelector('#diary-goto-profile')
?.addEventListener('click', () => App.navigate('dog-profile'));
return;
}
await _renderDiary();
}
// ----------------------------------------------------------
// DIARY-ANSICHT — Timeline mit Einträgen
// ----------------------------------------------------------
async function _renderDiary() {
_container.innerHTML = `
${UI.dogChip(_appState)}
`;
UI.bindDogChip(_container, _appState);
_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-fab')
?.addEventListener('click', () => _showForm(null));
_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 Promise.all([_load(), _loadStats()]);
_renderList();
_renderStatsBar();
_loadPraise();
}
// ----------------------------------------------------------
// FORTSCHRITTS-LOBER
// ----------------------------------------------------------
async function _loadPraise() {
const dog = _appState.activeDog;
if (!dog) return;
const existing = _container.querySelector('#diary-praise-card');
if (existing) existing.remove();
let data;
try {
const r = await fetch(`/api/praise/current?dog_id=${dog.id}`, {credentials: 'include'});
data = r.ok ? await r.json() : null;
} catch (_) { return; }
if (!data?.praise) return;
const card = document.createElement('div');
card.id = 'diary-praise-card';
card.style.cssText = `
margin: var(--space-3) var(--space-4) 0;
background: linear-gradient(135deg, var(--c-primary-subtle), #fdf6ef);
border: 1px solid var(--c-primary-light, #e8c99a);
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
display: flex; gap: var(--space-3); align-items: flex-start;
`;
card.innerHTML = `
🐾
Rückblick der Woche
${data.praise}
`;
const list = _container.querySelector('#diary-list');
if (list) _container.insertBefore(card, list);
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
card.style.opacity = '0';
card.style.transition = 'opacity .2s';
setTimeout(() => card.remove(), 200);
});
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _load() {
const dog = _appState.activeDog;
if (!dog) return;
const cacheKey = _CACHE_KEY + '_' + dog.id;
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);
if (_offset === 0 && !_searchQuery && !_filterMilestone) {
try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: batch })); } catch {}
}
// "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';
}
// Stats-Bar befüllen
_renderStatsBar();
} catch {
try {
const raw = localStorage.getItem(cacheKey);
if (raw) {
const cached = JSON.parse(raw).data || [];
_entries = cached;
_renderStatsBar();
UI.toast.info('Offline — zeige zuletzt geladene Einträge.');
return;
}
} catch {}
UI.toast.error('Einträge konnten nicht geladen werden.');
}
}
let _currentView = 'list'; // 'list' | 'media' | 'calendar' | 'map'
let _totalStats = null; // {entries, photos, days} — Gesamtstatistik aus API
async function _loadStats() {
const dog = _appState.activeDog;
if (!dog) return;
try {
_totalStats = await API.diary.stats(dog.id);
} catch (_) {}
}
function _fmt(n) {
if (n >= 10000) return (n / 1000).toFixed(0) + 'k';
if (n >= 1000) return (n / 1000).toFixed(1).replace('.0','') + 'k';
return String(n);
}
function _renderStatsBar() {
const bar = _container.querySelector('#diary-stats-bar');
if (!bar) return;
const s = _totalStats;
if (!s && _entries.length === 0) { bar.style.display = 'none'; return; }
const entries = s?.entries ?? _entries.length;
const photos = s?.photos ?? _entries.reduce((n, e) => n + _allMedia(e).length, 0);
const days = s?.days ?? new Set(_entries.map(e => e.datum).filter(Boolean)).size;
bar.innerHTML = `
${_fmt(entries)}
Einträge
${_fmt(photos)}
Medien
${_fmt(days)}
Tage
`;
bar.style.display = 'flex';
bar.querySelectorAll('.diary-view-btn').forEach(btn => {
btn.addEventListener('click', () => {
_currentView = btn.dataset.view;
_renderStatsBar();
_renderCurrentView();
});
});
}
function _renderCurrentView() {
const content = _container.querySelector('#diary-view-content');
const loadMore = _container.querySelector('#diary-load-more');
if (!content) return;
// "Weitere laden" nur in der Listenansicht sinnvoll
if (loadMore) loadMore.style.display = 'none';
if (_currentView === 'list') {
content.innerHTML = '';
_renderList();
// Sichtbarkeit des "Weitere laden"-Buttons nach _load() steuern (bereits in _load())
} else if (_currentView === 'media') {
_renderMediaGrid(content);
} else if (_currentView === 'calendar') {
_renderCalendarView(content);
} else if (_currentView === 'map') {
_renderMapView(content);
}
}
async function _renderMapView(content) {
const dog = _appState.activeDog;
if (!dog) return;
content.innerHTML = ``;
let locations;
try {
locations = await API.diary.locations(dog.id);
} catch (e) {
content.innerHTML = `Standorte konnten nicht geladen werden.
`;
return;
}
if (!locations.length) {
content.innerHTML = UI.emptyState({ icon: UI.icon('map-pin'), title: 'Keine Standorte', text: 'Füge GPS-Koordinaten zu Tagebucheinträgen hinzu.' });
return;
}
// Leaflet laden
if (!window.L) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = `/js/leaflet.js?v=${APP_VER}`;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
}
const mapEl = content.querySelector('#diary-map-view');
if (!mapEl) return;
// Bounds aus allen Punkten berechnen
const lats = locations.map(l => l.gps_lat);
const lons = locations.map(l => l.gps_lon);
const bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
// Marker für jeden Eintrag
locations.forEach(loc => {
const hasPhoto = !!loc.cover_url;
const dateStr = loc.datum ? new Date(loc.datum+'T12:00').toLocaleDateString('de-DE', {day:'numeric',month:'short',year:'numeric'}) : '';
const title = UI.escape(loc.titel || loc.location_name || dateStr);
const icon = L.divIcon({
html: hasPhoto
? ``
: ``,
iconSize: hasPhoto ? [44, 44] : [32, 32],
iconAnchor: hasPhoto ? [22, 22] : [16, 16],
className: '',
});
const marker = L.marker([loc.gps_lat, loc.gps_lon], { icon });
marker.bindPopup(`
`, { maxWidth: 200 });
marker.on('popupopen', () => {
setTimeout(() => {
document.querySelectorAll('.diary-map-popup').forEach(el => {
el.addEventListener('click', async () => {
map.closePopup();
const id = parseInt(el.dataset.id);
// Eintrag aus _entries holen oder per API nachladen
if (!_entries.find(e => e.id === id)) {
try {
const fresh = await API.diary.get(_appState.activeDog.id, id);
_entries.unshift(fresh);
} catch { return; }
}
_openDetail(id);
});
});
}, 50);
});
marker.addTo(map);
});
// Karte auf alle Punkte zoomen
if (locations.length === 1) {
map.setView([locations[0].gps_lat, locations[0].gps_lon], 14);
} else {
map.fitBounds(bounds, { padding: [40, 40] });
}
setTimeout(() => map.invalidateSize(), 100);
}
function _renderMediaGrid(content) {
const allMedia = [];
_entries.forEach(e => {
_allMedia(e).forEach(m => {
if (m.media_type === 'image') allMedia.push({ url: m.url, preview_url: m.preview_url, entryId: e.id, datum: e.datum });
});
});
if (allMedia.length === 0) {
content.innerHTML = UI.emptyState({ icon: UI.icon('images'), title: 'Keine Medien', text: 'Füge Fotos oder Videos zu Tagebucheinträgen hinzu.' });
return;
}
content.innerHTML = ``;
content.querySelectorAll('.diary-mosaic-item').forEach(el => {
el.addEventListener('click', () => _openDetail(parseInt(el.dataset.entryId)));
});
}
async function _renderCalendarView(content) {
const dog = _appState.activeDog;
if (!dog) return;
const today = new Date().toISOString().slice(0, 10);
const now = new Date();
let year = now.getFullYear(), month = now.getMonth();
content.innerHTML = `
Kalender wird geladen…
`;
let byDate = {};
try {
const all = await API.diary.calendar(dog.id);
if (!Array.isArray(all)) throw new Error('Keine Array-Antwort: ' + typeof all);
all.forEach(e => { if (e && e.datum) byDate[e.datum] = e; });
} catch (err) {
content.innerHTML = `Kalender-Fehler: ${UI.escape(String(err))}
`;
return;
}
// Debug: Anzahl geladener Einträge kurz anzeigen
const _total = Object.keys(byDate).length;
if (_total === 0) {
content.innerHTML = `Keine Einträge mit Datum gefunden.
`;
return;
}
const render = () => {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthName = new Date(year, month).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
const monthPrefix = `${year}-${String(month+1).padStart(2,'0')}`;
const monthCount = Object.keys(byDate).filter(k => k.startsWith(monthPrefix)).length;
const DAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
const offset = firstDay === 0 ? 6 : firstDay - 1;
const cells = [];
for (let i = 0; i < offset; i++) cells.push('');
for (let d = 1; d <= daysInMonth; d++) {
const key = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const entry = byDate[key];
cells.push(`
${entry?.cover_url ? `
})
` : ''}
${d}
`);
}
// Nächsten/vorherigen Monat MIT Einträgen finden für Sprungbuttons
const allMonths = [...new Set(Object.keys(byDate).map(k => k.slice(0,7)))].sort();
const curM = monthPrefix;
const prevM = allMonths.filter(m => m < curM).at(-1) || null;
const nextM = allMonths.filter(m => m > curM)[0] || null;
content.innerHTML = `
${monthName}${monthCount > 0 ? `${monthCount}` : ''}
${DAYS.map(n=>`
${n}
`).join('')}
${cells.join('')}
`;
};
// Event-Delegation auf content — überlebt innerHTML-Erneuerungen
content.addEventListener('click', async e => {
const navBtn = e.target.closest('.cal-nav-btn');
if (navBtn) {
// Sprungbutton: direkt zu Monat mit Einträgen
if (navBtn.dataset.jump) {
const [y, m] = navBtn.dataset.jump.split('-').map(Number);
year = y; month = m - 1;
render();
return;
}
const dir = parseInt(navBtn.dataset.dir);
month += dir;
if (month < 0) { month = 11; year--; }
if (month > 11) { month = 0; year++; }
render();
return;
}
const cell = e.target.closest('.diary-cal-cell.has-entry');
if (cell) {
const id = parseInt(cell.dataset.entryId);
if (!id) return;
if (!_entries.find(en => en.id === id)) {
try {
const fresh = await API.diary.get(dog.id, id);
_entries.unshift(fresh);
} catch { return; }
}
_openDetail(id);
}
});
render();
}
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 (Day One Style)
// ----------------------------------------------------------
function _renderList() {
const listEl = _container.querySelector('#diary-list');
if (!listEl) return;
const dog = _appState.activeDog;
const isSitter = dog?.is_guest === true;
// Sitter: Einträge grundsätzlich ausgeblendet — nur Hinweis + FAB bleibt aktiv
if (isSitter) {
listEl.innerHTML = UI.emptyState({
icon: UI.icon('lock-simple'),
title: 'Einträge nicht sichtbar',
text: 'Du kannst neue Einträge hinzufügen, aber keine bestehenden Einträge sehen.',
});
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: ``,
});
listEl.querySelector('#diary-first-entry')
?.addEventListener('click', () => _showForm(null));
return;
}
// Datenschutz-Hinweis: einmalig anzeigen, per Klick wegklicken
const privacyNotice = localStorage.getItem('by_diary_privacy_ack') ? '' : `
Deine Tagebucheinträge sind privat — nur du kannst sie sehen.
`;
// 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 = privacyNotice;
groups.forEach((items, key) => {
const monthLabel = key === 'unbekannt' ? 'Datum unbekannt' : _formatMonth(key);
html += ``;
html += ``;
html += items.map(e => _entryCard(e)).join('');
html += `
`;
});
listEl.innerHTML = html;
// Datenschutz-Hinweis wegklicken
listEl.querySelector('#diary-privacy-notice')?.addEventListener('click', () => {
localStorage.setItem('by_diary_privacy_ack', '1');
listEl.querySelector('#diary-privacy-notice')?.remove();
});
// Events an Karten binden
listEl.querySelectorAll('[data-entry-id]').forEach(card => {
const id = parseInt(card.dataset.entryId);
card.addEventListener('click', () => _openDetail(id));
});
listEl.querySelectorAll('[data-action="open-note"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = parseInt(btn.dataset.entryId);
const label = btn.dataset.label || '';
const location = btn.dataset.location || null;
UI.noteModal('diary', id, label, location || null);
});
});
}
// ----------------------------------------------------------
// ENTRY CARD — Day One Row-Style
// ----------------------------------------------------------
function _entryCard(e) {
const isMile = e.is_milestone || e.typ === 'meilenstein';
const allMedia = _allMedia(e);
const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null;
const mediaCount = allMedia.length;
// Thumbnail rechts (72×72)
let photoHtml = '';
if (coverMedia) {
if (coverMedia.media_type === 'video') {
photoHtml = `
${mediaCount > 1 ? `
${mediaCount}` : ''}
`;
} else {
photoHtml = `

${mediaCount > 1 ? `
${mediaCount}` : ''}
`;
}
}
// Vorschautext (max 2 Zeilen via CSS clamp)
const cleanedText = e.text ? _cleanText(e.text) : '';
const textPreview = cleanedText
? `${UI.escape(cleanedText.slice(0, 160))}
`
: '';
// Meta-Zeile: Zeit · 📍 Ort · Wetter
const metaParts = [];
if (e.created_at) {
const t = _timeStr(e.created_at);
if (t) metaParts.push(`${t}`);
}
if (e.location_name) {
metaParts.push(`${UI.escape(e.location_name)}`);
}
if (e.weather_json) {
try {
const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json;
const temp = w?.temp_c ?? w?.temperature_2m;
if (temp != null) {
metaParts.push(`${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°`);
}
} catch (_) {}
}
const metaRow = metaParts.length
? `${metaParts.join(' · ')}
`
: '';
// Meilenstein-Icon auf der Datum-Spalte
const mileIcon = isMile
? ``
: '';
// Titel oder Typ als Fallback
const typObj = TYPEN[e.typ] || TYPEN.eintrag;
const titleText = e.titel
? `${UI.escape(e.titel)}
`
: `${typObj.label}
`;
const noteLabel = e.titel || e.datum || '';
return `
${_weekday(e.datum)}
${_dayNum(e.datum)}
${mileIcon}
${titleText}
${textPreview}
${metaRow}
${photoHtml}
`;
}
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 `
${dog.foto_url ? `
})
` : `
${UI.icon('dog')}`}
`;
}).join('');
return `${avatars}
`;
}
// ----------------------------------------------------------
// 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 = `
${photos.length > 1 ? `${idx+1} / ${photos.length}` : ''}
${photos.length > 1 ? `
` : '
'}
`;
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 Day One-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];
// Hunde-Chips (bei mehreren Hunden)
const dogsHtml = dogIds.length > 1
? `
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `
${dog.foto_url ? `
})
` : `
${UI.icon('dog')}`}
${UI.escape(dog.name)} ` : '';
}).join('')}
` : '';
// Detail-View im Content-Bereich rendern (gleiche Breite wie Liste/Kalender)
const content = _container.querySelector('#diary-view-content');
if (!content) return;
// FAB + "Weitere laden" ausblenden während Detail offen
_container.querySelector('#diary-fab')?.style.setProperty('display','none');
const _lm = _container.querySelector('#diary-load-more');
if (_lm) _lm.style.display = 'none';
const view = document.createElement('div');
view.id = 'diary-detail-view';
view.className = 'diary-detail-view-inner';
// Medien-HTML für Hero-Bereich (45vh)
const _heroHtml = (m) => {
if (m.media_type === 'pdf') {
return `
${UI.escape(m.url.split('/').pop())}
PDF öffnen
`;
}
if (m.media_type === 'video') {
return ``;
}
return `
`;
};
// Hero-Sektion
let heroSection = '';
if (allMedia.length >= 1) {
const thumbsHtml = allMedia.length > 1
? `
${allMedia.map((m, i) => `
${m.media_type === 'pdf'
? `
`
: m.media_type === 'video'
? `
`
: `
})
`}
`).join('')}
`
: '';
heroSection = `
${_heroHtml(allMedia[0])}
${thumbsHtml}`;
}
// Datum lang formatiert: "Montag, 21. April 2026"
const datumLang = _formatDateLong(entry.datum);
// Meta-Bar: Zeit · Ort · Wetter (korrekte Feldnamen aus Open-Meteo)
const metaItems = [];
if (entry.created_at) {
const t = _timeStr(entry.created_at);
if (t) metaItems.push(`${t}`);
}
if (entry.location_name) {
const locContent = entry.gps_lat
? `${UI.escape(entry.location_name)}`
: UI.escape(entry.location_name);
metaItems.push(`${locContent}`);
}
if (entry.weather_json) {
try {
const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json;
const temp = w?.temp_c ?? w?.temperature_2m;
if (w && temp != null) {
const wind = w.wind_kmh ?? w.wind_speed_10m;
const precip = w.precip_prob;
const parts = [
`${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`,
wind != null ? `${Math.round(wind)} km/h Wind` : null,
precip != null ? `${precip}% Regen` : null,
].filter(Boolean).join(' · ');
metaItems.push(`${parts}`);
}
} catch (_) {}
}
const metaBar = metaItems.length
? `${metaItems.join('')}
`
: '';
// Tags
const tagsSection = tags.length
? `
${tags.map(t => `${t}`).join('')}
`
: '';
// POI-Liste (wie Routen)
const POI_ICON = { restaurant:'fork-knife', cafe:'coffee', bar:'beer-bottle',
pharmacy:'first-aid', hospital:'first-aid', park:'tree', playground:'soccer-ball',
supermarket:'shopping-cart', shop:'shopping-bag', attraction:'star',
viewpoint:'binoculars', museum:'buildings', hotel:'bed', church:'church',
school:'graduation-cap', default:'map-pin' };
let poiListHtml = '';
if (entry.poi_json) {
try {
const pois = typeof entry.poi_json === 'string' ? JSON.parse(entry.poi_json) : entry.poi_json;
if (pois?.length) {
poiListHtml = `
In der Nähe
${pois.map(p => {
const icon = POI_ICON[p.type] || POI_ICON.default;
const dist = p.distance_m < 1000 ? `${p.distance_m} m` : `${(p.distance_m/1000).toFixed(1)} km`;
return `
${UI.escape(p.name)}
${dist}
`;
}).join('')}
`;
}
} catch (_) {}
}
// Karte (wenn GPS vorhanden) — Platzhalter-Div, wird nach DOM-Insert befüllt
const hasGps = entry.gps_lat != null && entry.gps_lon != null;
const mapSection = hasGps
? ``
: '';
view.innerHTML = `
${heroSection}
${isMile ? `
Meilenstein
` : ''}
${entry.titel ? `
${UI.escape(entry.titel)}
` : ''}
${metaBar}
${dogsHtml}
${entry.text
? `
${UI.escape(_cleanText(entry.text))}
`
: ''}
${metaItems.length || entry.text ? '
' : ''}
${typ.icon} ${typ.label}
${tagsSection}
${mapSection}
`;
// In Content-Bereich einsetzen statt als Fixed-Overlay
content.innerHTML = '';
content.appendChild(view);
UI.scrollTop(); // Seite nach oben scrollen
// Leaflet-Karte initialisieren (wenn GPS vorhanden)
if (hasGps) {
setTimeout(async () => {
const mapEl = view.querySelector('#diary-dv-map');
if (!mapEl) return;
if (!window.L) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = `/js/leaflet.js?v=${APP_VER}`;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
}
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
const svgIcon = L.divIcon({
html: ``,
iconSize: [32, 32], iconAnchor: [16, 16], className: '',
});
L.marker([entry.gps_lat, entry.gps_lon], { icon: svgIcon }).addTo(map);
map.setView([entry.gps_lat, entry.gps_lon], 15);
map.invalidateSize();
}, 150);
}
// Zurück → vorherige Ansicht wiederherstellen
const _closeDetail = () => {
_container.querySelector('#diary-fab')?.style.removeProperty('display');
_renderCurrentView();
_renderStatsBar();
};
view.querySelector('#diary-dv-back').addEventListener('click', _closeDetail);
// Notiz-Button in Detailansicht
view.querySelector('#diary-dv-note')?.addEventListener('click', e => {
e.stopPropagation();
const label = entry.titel || entry.datum || String(entry.id);
UI.noteModal('diary', entry.id, label, entry.location_name || null);
});
// Bearbeiten
view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => {
_container.querySelector('#diary-fab')?.style.removeProperty('display');
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', ev => {
const clickedIdx = parseInt(ev.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', ev => {
const thumb = ev.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', ev2 => {
const clickedIdx = parseInt(ev2.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 .diary-detail-thumb').forEach((t, j) => {
t.classList.toggle('diary-detail-thumb--active', j === i);
});
});
}
// ----------------------------------------------------------
// 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 }]) =>
``)
.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 ? `
` : '';
const body = `
`;
const footer = `
${isEdit ? `` : ''}
`;
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')
? ``
: f.type.startsWith('video/')
? ``
: `
`;
return `
${thumb}
`;
}).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 = `
${items.map((m, idx) => `
${m.media_type === 'video'
? `
`
: `

`}
${m.id != null ? `
` : ''}
`).join('')}
`;
wrap.innerHTML = grid;
wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', async () => {
const wrap2 = btn.closest('.diary-media-thumb-wrap') || btn.parentElement;
const mediaId = btn.dataset.mediaId ? parseInt(btn.dataset.mediaId) : null;
const isLegacy = !!btn.dataset.legacy;
btn.disabled = true;
let alreadyGone = false;
try {
if (mediaId != null) {
await API.diary.deleteMediaItem(_appState.activeDog.id, entry.id, mediaId);
} else if (isLegacy) {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
}
} catch (e) {
if (e?.status === 404) {
alreadyGone = true; // serverseitig schon weg → trotzdem lokal aufräumen
} else {
btn.disabled = false;
UI.toast.error(e.message || 'Fehler beim Löschen.');
return;
}
}
if (mediaId != null && entry.media_items) {
entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
} else if (isLegacy) {
entry.media_url = null;
}
if (wrap2) wrap2.remove();
UI.toast.success(alreadyGone ? 'Verwaisten Eintrag aufgeräumt.' : 'Medium entfernt.');
});
});
// 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 = '';
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 = 'Keine Orte in der Nähe gefunden.
';
} else {
sugEl.innerHTML = suggestions.map(s => `
`).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 () => {
// Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort und OHNE anhängige Dateien.
// Wenn Dateien hochzuladen sind, dürfen wir keinen langen await vor dem Upload machen —
// iOS macht File-Handles nach längerer Pause ungültig (WebKit-Bug).
let _clientWeather = null;
if (!isEdit && _locLat == null && _newFiles.length === 0) {
try {
const pos = await API.getLocation();
const wd = await API.weather.get(pos.lat, pos.lon);
if (wd && wd.temp_c != null) _clientWeather = JSON.stringify(wd);
} catch (_) { /* GPS oder Wetter nicht verfügbar → kein Problem */ }
}
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,
client_time: API.clientNow(),
weather_json: _clientWeather,
};
async function _uploadNewFiles(entryId) {
const total = _newFiles.length;
const saveBtn = document.querySelector('button[form="diary-form"]');
let done = 0;
if (saveBtn) saveBtn.textContent = `0 von ${total} hochgeladen…`;
const results = await Promise.all(_newFiles.map(async file => {
// Bild-Kompression vor Upload (HEIC/Video/<500KB werden unverändert durchgereicht)
const toUpload = await API.compressImage(file);
const formData = new FormData();
formData.append('file', toUpload);
try {
const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData);
if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`;
return { ok: true, m };
} catch {
if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`;
return { ok: false };
}
}));
const uploaded = results.filter(r => r.ok).map(r => r.m);
const failCount = results.filter(r => !r.ok).length;
const exifGps = results.find(r => r.ok && r.m.exif_lat != null)?.m;
if (failCount > 0) {
UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`);
}
if (exifGps) {
UI.toast.success(`📍 Standort aus Foto-GPS übernommen`);
}
return { uploaded, exifGps: exifGps ? { lat: exifGps.exif_lat, lon: exifGps.exif_lon } : null };
}
if (isEdit) {
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload);
if (_newFiles.length > 0) {
const { uploaded, exifGps } = await _uploadNewFiles(entry.id);
if (!updated.media_items) updated.media_items = [];
updated.media_items.push(...uploaded);
if (exifGps && !updated.gps_lat) {
updated.gps_lat = exifGps.lat;
updated.gps_lon = exifGps.lon;
}
} else {
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 (created?._queued) { UI.modal.close(); return; }
if (_newFiles.length > 0) {
const { uploaded, exifGps } = await _uploadNewFiles(created.id);
created.media_items = uploaded;
if (exifGps && !created.gps_lat) {
created.gps_lat = exifGps.lat;
created.gps_lon = exifGps.lon;
}
}
_entries.unshift(created);
UI.toast.success('Eintrag erstellt.');
}
UI.modal.close();
_renderList();
_loadStats().then(() => _renderStatsBar());
});
});
}
// ----------------------------------------------------------
// 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();
_loadStats().then(() => _renderStatsBar());
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: `
Importiere Einträge aus einer anderen App in das Tagebuch von
${UI.escape(_appState.activeDog?.name || 'deinem Hund')}.
`,
footer: `
`,
});
// 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
? `${res.errors.length} Fehler anzeigen
${UI.escape(res.errors.join('\n'))} `
: '';
resultEl.innerHTML = `
${res.imported} Einträge importiert
${res.skipped ? ` · ${res.skipped} übersprungen` : ''}
${errHtml}
`;
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 = `
Fehler: ${UI.escape(e.message || String(e))}
`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
}
});
}
// ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
// ----------------------------------------------------------
// 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 };
})();