banyaro/backend/static/js/pages/diary.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

2049 lines
93 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 = (() => {
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 `<span class="diary-weather-badge" title="${UI.escape(w.desc || '')}">${emoji} ${temp}°</span>`;
}
/** 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 `<p class="diary-poi-chips">${chips}</p>`;
}
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;
_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: '<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;
}
await _renderDiary();
}
// ----------------------------------------------------------
// DIARY-ANSICHT — Timeline mit Einträgen
// ----------------------------------------------------------
async function _renderDiary() {
_container.innerHTML = `
${UI.dogChip(_appState)}
<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-stats-bar" class="diary-stats-bar hidden"></div>
<div id="diary-view-content">
<div id="diary-list"></div>
</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>
<!-- FAB: Neuer Eintrag -->
<button id="diary-fab" class="diary-fab" aria-label="Neuer Tagebucheintrag">
<svg class="ph-icon" style="width:26px;height:26px" aria-hidden="true">
<use href="/icons/phosphor.svg#plus"></use>
</svg>
</button>
`;
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 = `
<div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div>
<div class="flex-1-min">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)">
Rückblick der Woche
</div>
<p style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.6;margin:0">${data.praise}</p>
</div>
<button id="diary-praise-close"
style="background:none;border:none;cursor:pointer;padding:2px;
color:var(--c-text-muted);flex-shrink:0;line-height:1;font-size:1.1rem"
aria-label="Schließen">×</button>
`;
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 = `
<div class="diary-stats-numbers">
<div class="diary-stat">
<span class="diary-stat-num">${_fmt(entries)}</span>
<span class="diary-stat-label">Einträge</span>
</div>
<div class="diary-stat">
<span class="diary-stat-num">${_fmt(photos)}</span>
<span class="diary-stat-label">Medien</span>
</div>
<div class="diary-stat">
<span class="diary-stat-num">${_fmt(days)}</span>
<span class="diary-stat-label">Tage</span>
</div>
</div>
<div class="diary-view-switcher">
<button class="diary-view-btn${_currentView==='list'?' active':''}" data-view="list" title="Liste">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
</button>
<button class="diary-view-btn${_currentView==='media'?' active':''}" data-view="media" title="Medien">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
</button>
<button class="diary-view-btn${_currentView==='calendar'?' active':''}" data-view="calendar" title="Kalender">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg>
</button>
<button class="diary-view-btn${_currentView==='map'?' active':''}" data-view="map" title="Karte">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
</button>
</div>
`;
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 = '<div id="diary-list"></div>';
_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 = `<div id="diary-map-view" style="height:calc(100vh - 200px);min-height:400px;position:relative">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--c-text-muted);font-size:14px">
<svg class="ph-icon" style="width:24px;height:24px;margin-right:8px" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
Karte wird geladen…
</div>
</div>`;
let locations;
try {
locations = await API.diary.locations(dog.id);
} catch (e) {
content.innerHTML = `<p style="padding:20px;color:var(--c-danger)">Standorte konnten nicht geladen werden.</p>`;
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
? `<div style="width:44px;height:44px;border-radius:50%;overflow:hidden;border:3px solid var(--c-primary,#C4843A);box-shadow:0 2px 8px rgba(0,0,0,.3);background:#fff">
<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100%;object-fit:cover" onerror="this.src='${UI.escape(loc.cover_url)}'">
</div>`
: `<div style="width:32px;height:32px;border-radius:50%;background:var(--c-primary,#C4843A);border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center">
<svg style="width:16px;height:16px;fill:#fff" viewBox="0 0 256 256"><path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,192Zm0-104a24,24,0,1,0,24,24A24,24,0,0,0,128,88Z"/></svg>
</div>`,
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(`
<div style="min-width:160px;cursor:pointer" class="diary-map-popup" data-id="${loc.id}">
${hasPhoto ? `<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100px;object-fit:cover;border-radius:6px;display:block;margin-bottom:8px" onerror="this.src='${UI.escape(loc.cover_url)}'">` : ''}
<div style="font-weight:600;font-size:13px;margin-bottom:2px">${title}</div>
<div style="font-size:11px;color:#888">${dateStr}</div>
${loc.media_count > 1 ? `<div style="font-size:11px;color:#888;margin-top:2px">📷 ${loc.media_count} Medien</div>` : ''}
<div style="margin-top:6px;text-align:center;font-size:12px;color:var(--c-primary,#C4843A);font-weight:600">→ Öffnen</div>
</div>`, { 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 = `<div class="diary-media-mosaic">${
allMedia.map(m => `
<div class="diary-mosaic-item" data-entry-id="${m.entryId}" data-full-url="${UI.escape(m.url)}">
<img src="${UI.escape(m.preview_url || m.url)}"
${m.preview_url ? `srcset="${UI.escape(m.preview_url)} 800w, ${UI.escape(m.url)} 2000w" sizes="(max-width:400px) 200px, 400px"` : ''}
alt="" loading="lazy"
onerror="this.src='${UI.escape(m.url)}'">
</div>`).join('')
}</div>`;
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 = `<div style="padding:40px;text-align:center;color:var(--c-text-muted)">
<svg class="ph-icon" style="width:24px;height:24px;fill:currentColor" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg>
<span style="margin-left:8px">Kalender wird geladen…</span></div>`;
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 = `<p style="padding:20px;color:var(--c-danger)">Kalender-Fehler: ${UI.escape(String(err))}</p>`;
return;
}
// Debug: Anzahl geladener Einträge kurz anzeigen
const _total = Object.keys(byDate).length;
if (_total === 0) {
content.innerHTML = `<p style="padding:20px;color:var(--c-text-muted)">Keine Einträge mit Datum gefunden.</p>`;
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('<div></div>');
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(`<div class="diary-cal-cell${entry?' has-entry':''}${key===today?' today':''}" data-entry-id="${entry?.id||''}">
${entry?.cover_url ? `<img src="${UI.escape(entry.cover_preview_url || entry.cover_url)}" alt="" loading="lazy" onerror="this.src='${UI.escape(entry.cover_url)}'">` : ''}
<span class="diary-cal-day">${d}</span>
</div>`);
}
// 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 = `
<div class="diary-calendar">
<div class="diary-cal-nav">
<div style="display:flex;gap:2px">
${prevM ? `<button class="cal-nav-btn cal-jump-btn" data-jump="${prevM}" title="Springe zu ${prevM}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-double-left"></use></svg>
</button>` : '<div style="width:32px"></div>'}
<button class="cal-nav-btn" data-dir="-1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-left"></use></svg>
</button>
</div>
<span class="diary-cal-month">${monthName}${monthCount > 0 ? `<span style="font-size:11px;font-weight:400;color:var(--c-primary);margin-left:6px">${monthCount}</span>` : ''}</span>
<div style="display:flex;gap:2px">
<button class="cal-nav-btn" data-dir="1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-right"></use></svg>
</button>
${nextM ? `<button class="cal-nav-btn cal-jump-btn" data-jump="${nextM}" title="Springe zu ${nextM}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-double-right"></use></svg>
</button>` : '<div style="width:32px"></div>'}
</div>
</div>
<div class="diary-cal-weekdays">${DAYS.map(n=>`<div>${n}</div>`).join('')}</div>
<div class="diary-cal-grid">${cells.join('')}</div>
</div>`;
};
// 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: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`,
});
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') ? '' : `
<div id="diary-privacy-notice" style="font-size:var(--text-xs);color:var(--c-text-muted);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);display:flex;align-items:center;
gap:var(--space-2);margin-bottom:var(--space-3);cursor:pointer"
title="Klicken zum Schließen">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;flex-shrink:0">
<use href="/icons/phosphor.svg#lock-simple"></use></svg>
Deine Tagebucheinträge sind privat — nur du kannst sie sehen.
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;margin-left:auto;flex-shrink:0;opacity:0.5">
<use href="/icons/phosphor.svg#x"></use></svg>
</div>`;
// 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 += `<div class="diary-month-header">${monthLabel}</div>`;
html += `<div class="diary-month-entries">`;
html += items.map(e => _entryCard(e)).join('');
html += `</div>`;
});
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;
_openNoteModal('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 = `<div class="diary-card-photo">
<div class="diary-card-video-thumb" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--c-surface-2);color:var(--c-primary)">
<svg class="ph-icon" style="width:28px;height:28px" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg>
</div>
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`;
} else {
photoHtml = `<div class="diary-card-photo">
<img src="${e.cover_preview_url || e.cover_url || coverMedia.preview_url || coverMedia.url}"
${(e.cover_preview_url && e.cover_url) ? `srcset="${UI.escape(e.cover_preview_url)} 800w, ${UI.escape(e.cover_url)} 2000w" sizes="(max-width:600px) 300px, 600px"` : ''}
alt="Foto" loading="lazy"
${e.cover_url ? `onerror="this.src='${UI.escape(e.cover_url)}'"` : ''}>
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`;
}
}
// Vorschautext (max 2 Zeilen via CSS clamp)
const cleanedText = e.text ? _cleanText(e.text) : '';
const textPreview = cleanedText
? `<p class="diary-card-text">${UI.escape(cleanedText.slice(0, 160))}</p>`
: '';
// Meta-Zeile: Zeit · 📍 Ort · Wetter
const metaParts = [];
if (e.created_at) {
const t = _timeStr(e.created_at);
if (t) metaParts.push(`<span class="diary-meta-time">${t}</span>`);
}
if (e.location_name) {
metaParts.push(`<span class="diary-meta-loc"><svg class="ph-icon" style="width:11px;height:11px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${UI.escape(e.location_name)}</span>`);
}
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(`<span class="diary-meta-weather">${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°</span>`);
}
} catch (_) {}
}
const metaRow = metaParts.length
? `<div class="diary-card-meta-row">${metaParts.join('<span class="diary-meta-dot"> · </span>')}</div>`
: '';
// Meilenstein-Icon auf der Datum-Spalte
const mileIcon = isMile
? `<svg class="ph-icon diary-milestone-icon" style="width:14px;height:14px;color:#c4a000;margin-top:4px" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>`
: '';
// Titel oder Typ als Fallback
const typObj = TYPEN[e.typ] || TYPEN.eintrag;
const titleText = e.titel
? `<div class="diary-card-title">${UI.escape(e.titel)}</div>`
: `<div class="diary-card-title" style="font-weight:500;color:var(--c-text-secondary)">${typObj.label}</div>`;
const noteLabel = e.titel || e.datum || '';
return `
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
<!-- Datum-Spalte links -->
<div class="diary-card-date-col">
<span class="diary-card-weekday">${_weekday(e.datum)}</span>
<span class="diary-card-daynum">${_dayNum(e.datum)}</span>
${mileIcon}
</div>
<!-- Inhalt Mitte -->
<div class="diary-card-body">
${titleText}
${textPreview}
${metaRow}
</div>
<!-- Thumbnail rechts -->
${photoHtml}
</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="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">
</div>
<!-- Controls unten: Zurück · Counter · Prev/Next — mit Safe-Area für alle Ränder -->
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;
padding-top:10px;
padding-bottom:calc(env(safe-area-inset-bottom,0px) + 12px);
padding-left:calc(env(safe-area-inset-left,0px) + 16px);
padding-right:calc(env(safe-area-inset-right,0px) + 16px);
flex-shrink:0;background:rgba(0,0,0,.5);gap:8px">
<button id="lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:24px;
height:48px;color:#fff;cursor:pointer;display:flex;align-items:center;
justify-content:center;gap:6px;padding:0 16px;font-size:15px;font-weight:500">
<svg style="width:20px;height:20px;fill:currentColor;flex-shrink:0" viewBox="0 0 256 256">
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/>
</svg>
Zurück
</button>
<span style="color:rgba(255,255,255,.7);font-size:14px;text-align:center;white-space:nowrap">
${photos.length > 1 ? `${idx+1} / ${photos.length}` : ''}
</span>
${photos.length > 1 ? `
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="lb-prev" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;
width:48px;height:48px;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="background:rgba(255,255,255,.15);border:none;border-radius:50%;
width:48px;height:48px;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>` : '<div></div>'}
</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 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
? `<div class="diary-detail-dogs mb-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>` : '';
// 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 `<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:40px 16px;background:var(--c-surface-2);text-decoration:none;color:var(--c-text);height:100%">
<svg class="ph-icon" style="width:56px;height:56px;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>`;
}
if (m.media_type === 'video') {
return `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" controls playsinline style="width:100%;height:100%;display:block;object-fit:contain;background:#000"></video>`;
}
return `<img src="${UI.escape(m.url)}" data-idx="${allMedia.indexOf(m)}" style="width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in">`;
};
// Hero-Sektion
let heroSection = '';
if (allMedia.length >= 1) {
const thumbsHtml = allMedia.length > 1
? `<div class="diary-detail-thumbs" id="diary-dv-thumbs">
${allMedia.map((m, i) => `
<div class="diary-detail-thumb${i === 0 ? ' diary-detail-thumb--active' : ''}" data-idx="${i}">
${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:24px;height:24px;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;display:block">`}
</div>`).join('')}
</div>`
: '';
heroSection = `
<div class="diary-detail-hero" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>
${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(`<span class="diary-detail-meta-item"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>${t}</span>`);
}
if (entry.location_name) {
const locContent = 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;text-decoration:none">${UI.escape(entry.location_name)}</a>`
: UI.escape(entry.location_name);
metaItems.push(`<span class="diary-detail-meta-item"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${locContent}</span>`);
}
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(`<span class="diary-detail-meta-item">${parts}</span>`);
}
} catch (_) {}
}
const metaBar = metaItems.length
? `<div class="diary-detail-meta-bar">${metaItems.join('')}</div>`
: '';
// Tags
const tagsSection = tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:16px">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: '';
// 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 = `<div class="diary-detail-poi-list">
<div class="diary-detail-poi-heading">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
In der Nähe
</div>
${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 `<div class="diary-detail-poi-row">
<svg class="ph-icon diary-detail-poi-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${icon}"></use></svg>
<span class="diary-detail-poi-name">${UI.escape(p.name)}</span>
<span class="diary-detail-poi-dist">${dist}</span>
</div>`;
}).join('')}
</div>`;
}
} 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
? `<div class="diary-detail-map-wrap">
<div id="diary-dv-map" class="diary-detail-map"></div>
${poiListHtml}
</div>`
: '';
view.innerHTML = `
<div class="diary-detail-header">
<button id="diary-dv-back" class="diary-detail-back">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
Zurück
</button>
<span class="diary-detail-date-center">${datumLang}</span>
<div style="display:flex;align-items:center;gap:4px">
${!_appState?.activeDog?.is_guest
? `<button id="diary-dv-note" class="btn btn-ghost btn-xs" title="Notiz"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
</button>
<button id="diary-dv-edit" class="diary-detail-edit">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>`
: '<div style="width:40px"></div>'}
</div>
</div>
${heroSection}
<div class="diary-detail-body-wrap">
<div class="diary-detail-content">
${isMile ? `<div class="diary-detail-milestone-badge"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg> Meilenstein</div>` : ''}
${entry.titel ? `<h1 class="diary-detail-title">${UI.escape(entry.titel)}</h1>` : ''}
${metaBar}
${dogsHtml}
${entry.text
? `<p class="diary-detail-body">${UI.escape(_cleanText(entry.text))}</p>`
: ''}
${metaItems.length || entry.text ? '<hr class="diary-detail-divider">' : ''}
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
</div>
${tagsSection}
</div>
${mapSection}
</div>
`;
// 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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="32" height="32">
<circle cx="128" cy="128" r="96" fill="var(--c-primary,#C4843A)" opacity=".25"/>
<circle cx="128" cy="128" r="48" fill="var(--c-primary,#C4843A)"/>
</svg>`,
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);
_openNoteModal('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 }]) =>
`<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 class="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">
<!-- Bestehende Medien (Edit-Modus) -->
<div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid hidden"></div>
<!-- versteckter Input — multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple class="hidden">
<!-- Einzelner Button — iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="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>
<div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span class="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 class="mt-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' : ''} class="hidden">
<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>
</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 w-full">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
<div class="flex-gap-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') || 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 = '<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 () => {
// 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: `
<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 class="flex-col-gap-3">
<label class="import-format-card" id="fmt-nsx">
<input type="radio" name="import-fmt" value="nsx" checked class="hidden">
<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 class="text-xs-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" class="hidden">
<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 class="text-xs-muted">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
</div>
</label>
</div>
<div class="mt-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 class="mt-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 class="text-sm-muted"> · ${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);
}
});
}
// ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
document.getElementById('by-note-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'by-note-modal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = document.getElementById('by-note-text');
const saveBtn = document.getElementById('by-note-save');
const cancelBtn = document.getElementById('by-note-cancel');
const closeBtn = document.getElementById('by-note-close');
let existingNoteId = null;
try {
const existing = await API.notes.get(parentType, parentId);
if (existing?.id) {
existingNoteId = existing.id;
textarea.value = existing.text || '';
}
} catch (_) { /* keine Notiz vorhanden — ok */ }
setTimeout(() => textarea.focus(), 100);
const _close = () => overlay.remove();
closeBtn.addEventListener('click', _close);
cancelBtn.addEventListener('click', _close);
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
document.getElementById('by-note-form').addEventListener('submit', async e => {
e.preventDefault();
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {
await API.notes.create(parentType, parentId, payload);
}
UI.toast.success('Notiz gespeichert.');
_close();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
UI.setLoading(saveBtn, 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 };
})();