banyaro/backend/static/js/pages/diary.js
rene f8d354749d Feature: Tagebuch Ort/POI, Foto/Video-Edit, Modal-UX, iOS-Fixes
Tagebuch — Ort/POI (DayOne-ähnlich):
- diary.location_name Spalte, DiaryCreate/Update mit gps_lat/lon/location_name
- GET /api/dogs/{id}/diary/nearby: Overpass + Nominatim (vor {entry_id}-Route)
- Mini-Karte im Edit-Formular: Leaflet lazy, Edit-Modus, SVG-Pin
- Meilenstein-Toggle: Button statt Checkbox, Filter in Toolbar
- Datenmigration: 97 Ort-Einträge aus text → location_name

Tagebuch — Foto/Video:
- Foto/Video im Edit: Ersetzen + Löschen, DELETE media endpoint
- Media-Picker: Kamera/Mediathek/Datei Buttons
- Video-Wiedergabe (<video controls> in Detail + Edit)

Modal-UX (alle Edit-Karten vereinheitlicht):
- Footer-Pattern: [Speichern vollbreit] / [Löschen][Abbrechen]
- diary, dog-profile, events, health, places, walks, settings, sitting
- Löschen aus Detail-Modal → Edit-Form verschoben

iOS Mobile-Fixes:
- Auto-Zoom: input/select/textarea font-size 16px !important
- Scroll-Through: html.modal-open + touch-action:none auf Overlay
- Kein position:fixed mehr auf body (kein Scroll-Sprung)

PWA & Icons:
- icon-512-any.png + icon-192-any.png (quadratisch, maskable)
- manifest.json: purpose any/maskable getrennt
- Gesundheits-Icon: syringe → first-aid

Import-Fix:
- _HTMLStripper überspringt video/audio/script → kein "Video nicht gefunden" mehr
2026-04-18 11:56:54 +02:00

1091 lines
46 KiB
JavaScript

/* ============================================================
BAN YARO — Tagebuch (Sprint 1)
Seiten-Modul: Timeline aller Einträge, Erstellen, Bearbeiten,
Löschen, Foto-Upload, Meilensteine.
============================================================ */
window.Page_diary = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _entries = [];
let _offset = 0;
let _searchQuery = '';
let _filterMilestone = false;
const LIMIT = 20;
function _loadLeaflet() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
link.onload = res; link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _sourceIcon(source) {
if (source === 'places') return 'star';
if (source === 'osm') return 'map-pin';
return 'map-trifold';
}
const _VIDEO_EXT = new Set(['.mp4','.mov','.webm','.m4v','.avi']);
function _isVideo(url) {
if (!url) return false;
return _VIDEO_EXT.has(url.slice(url.lastIndexOf('.')).toLowerCase());
}
function _mediaHtml(url, style = '') {
if (!url) return '';
return _isVideo(url)
? `<video src="${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}">`;
}
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>' },
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;
_loadLeaflet(); // Leaflet im Hintergrund vorladen — kein await
await _render();
}
// ----------------------------------------------------------
// REFRESH — erneuter Navigations-Aufruf (Tap auf Tab)
// ----------------------------------------------------------
async function refresh() {
if (!_appState.activeDog) return;
// Mehrere Hunde → Picker zeigen (User kann Hund wählen)
if (_appState.dogs.length > 1) {
_renderDogPicker();
return;
}
// Einzelner Hund → Diary direkt neu laden
_offset = 0;
_entries = [];
await _renderDiary();
}
// ----------------------------------------------------------
// ON DOG CHANGE — vom Header-Switcher ausgelöst
// ----------------------------------------------------------
async function onDogChange(dog) {
_offset = 0;
_entries = [];
_searchQuery = '';
await _renderDiary();
}
// ----------------------------------------------------------
// OPEN NEW — vom + Button oder Quick-Add
// ----------------------------------------------------------
function openNew() {
_showForm(null);
}
// ----------------------------------------------------------
// RENDER — Einstieg: Picker bei mehreren Hunden, sonst direkt
// ----------------------------------------------------------
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil, um das Tagebuch zu nutzen.',
action: `<button class="btn btn-primary" id="diary-goto-profile">Profil erstellen</button>`,
});
_container.querySelector('#diary-goto-profile')
?.addEventListener('click', () => App.navigate('dog-profile'));
return;
}
if (_appState.dogs.length > 1) {
_renderDogPicker();
} else {
await _renderDiary();
}
}
// ----------------------------------------------------------
// HUNDE-PICKER — Einstiegsseite bei mehreren Hunden
// ----------------------------------------------------------
function _renderDogPicker() {
const activeDogId = _appState.activeDog?.id;
const cards = _appState.dogs.map(dog => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_escape(dog.foto_url)}" alt="${_escape(dog.name)}">`
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
<div class="diary-picker-av">${av}</div>
<div class="diary-picker-name">${_escape(dog.name)}</div>
${dog.rasse ? `<div class="diary-picker-rasse">${_escape(dog.rasse)}</div>` : ''}
</div>`;
}).join('');
_container.innerHTML = `
<div class="diary-picker-wrap">
<p class="diary-picker-hint">Wessen Tagebuch?</p>
<div class="diary-picker-grid">${cards}</div>
</div>`;
_container.querySelectorAll('.diary-picker-card').forEach(el => {
el.addEventListener('click', async () => {
const id = parseInt(el.dataset.dogId);
if (id === _appState.activeDog?.id) {
// Bereits aktiver Hund → direkt Diary laden
_offset = 0; _entries = [];
await _renderDiary();
} else {
App.setActiveDog(id);
// onDogChange() → _renderDiary() via _notifyDogChange()
}
});
});
}
// ----------------------------------------------------------
// DIARY-ANSICHT — Timeline mit Einträgen
// ----------------------------------------------------------
async function _renderDiary() {
_container.innerHTML = `
<div class="by-toolbar diary-toolbar">
<div class="diary-search-wrap" id="diary-search-wrap">
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" class="diary-search-input" id="diary-search-input"
placeholder="Einträge durchsuchen…" autocomplete="off">
</div>
<button class="btn btn-secondary btn-sm${_filterMilestone ? ' btn-active' : ''}" id="diary-milestone-filter" title="Nur Meilensteine">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
</button>
<button class="btn btn-secondary btn-sm" id="diary-import-btn" title="Importieren">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
</button>
</div>
<div id="diary-list"></div>
<div id="diary-load-more" style="display:none; text-align:center; padding:var(--space-4)">
<button class="btn btn-secondary" id="diary-btn-more">Weitere laden</button>
</div>
`;
_container.querySelector('#diary-milestone-filter')
?.addEventListener('click', async () => {
_filterMilestone = !_filterMilestone;
_offset = 0; _entries = [];
const btn = _container.querySelector('#diary-milestone-filter');
btn?.classList.toggle('btn-active', _filterMilestone);
await _load();
_renderList();
});
_container.querySelector('#diary-import-btn')
?.addEventListener('click', _showImport);
_container.querySelector('#diary-btn-more')
?.addEventListener('click', () => _loadMore());
// Suche mit Debounce
let _searchTimer = null;
_container.querySelector('#diary-search-input')
?.addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(async () => {
_offset = 0;
_entries = [];
_searchQuery = e.target.value.trim();
await _load();
_renderList();
}, 350);
});
await _load();
_renderList();
}
// ----------------------------------------------------------
// DATEN LADEN
// ----------------------------------------------------------
async function _load() {
const dog = _appState.activeDog;
if (!dog) return;
try {
const params = { limit: LIMIT, offset: _offset };
if (_searchQuery) params.q = _searchQuery;
if (_filterMilestone) params.milestone = 1;
const batch = await API.diary.list(dog.id, params);
_entries = _entries.concat(batch);
// "Mehr laden" anzeigen wenn volle Page geladen wurde
const loadMore = _container.querySelector('#diary-load-more');
if (loadMore) {
loadMore.style.display = batch.length === LIMIT ? 'block' : 'none';
}
} catch (err) {
UI.toast.error('Einträge konnten nicht geladen werden.');
}
}
async function _loadMore() {
_offset += LIMIT;
const btn = _container.querySelector('#diary-btn-more');
UI.setLoading(btn, true);
await _load();
_renderList();
UI.setLoading(btn, false);
}
// ----------------------------------------------------------
// EMPTY-STATE HELPER
// ----------------------------------------------------------
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// LISTE RENDERN — Timeline gruppiert nach Monat
// ----------------------------------------------------------
function _renderList() {
const listEl = _container.querySelector('#diary-list');
if (!listEl) return;
if (_entries.length === 0) {
listEl.innerHTML = _emptyState(
'book-open',
'Noch keine Tagebucheinträge',
'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
`<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`
);
listEl.querySelector('#diary-first-entry')
?.addEventListener('click', () => _showForm(null));
return;
}
// Gruppieren nach Jahr-Monat (Anzeigereihenfolge: chronologisch absteigend)
const groups = new Map();
_entries.forEach(e => {
const key = e.datum ? e.datum.slice(0, 7) : 'unbekannt'; // "2025-04"
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
});
let html = '';
groups.forEach((items, key) => {
const monthLabel = key === 'unbekannt' ? 'Datum unbekannt' : _formatMonth(key);
html += `<div class="diary-month-header">${monthLabel}</div>`;
html += items.map(e => _entryCard(e)).join('');
});
listEl.innerHTML = html;
// Events an Karten binden
listEl.querySelectorAll('[data-entry-id]').forEach(card => {
const id = parseInt(card.dataset.entryId);
card.addEventListener('click', () => _openDetail(id));
});
}
// ----------------------------------------------------------
// ENTRY CARD
// ----------------------------------------------------------
function _entryCard(e) {
const typ = TYPEN[e.typ] || TYPEN.eintrag;
const isMile = e.is_milestone || e.typ === 'meilenstein';
const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : '';
const tags = (e.tags || []).slice(0, 4);
const photo = e.media_url
? `<div class="diary-card-photo">
${_isVideo(e.media_url)
? `<div class="diary-card-video-thumb"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg></div>`
: `<img src="${e.media_url}" alt="Foto" loading="lazy">`}
</div>`
: '';
const tagsHtml = tags.length
? `<div class="diary-card-tags">${tags.map(t => `<span class="badge">${t}</span>`).join('')}</div>`
: '';
const locationHtml = e.location_name
? `<p class="diary-card-location"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${_escape(e.location_name)}</p>`
: '';
const textPreview = e.text
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
// Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein')
const milestoneBadge = e.is_milestone
? `<div class="diary-card-milestone-badge">${UI.icon('calendar-dots')} Meilenstein</div>`
: '';
// Mehrere Hunde: kleine Avatare in der Karte
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
return `
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo}
<div class="diary-card-body">
${milestoneBadge}
<div class="diary-card-meta">
<span class="diary-card-type">${typ.icon} ${typ.label}</span>
<span class="diary-card-date">${dateStr}</span>
</div>
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
${locationHtml}
${textPreview}
${tagsHtml}
${dogAvatars}
</div>
</div>
`;
}
function _dogAvatarRow(dogIds) {
if (!dogIds || dogIds.length <= 1) return '';
const avatars = dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// ----------------------------------------------------------
function _openDetail(entryId) {
const entry = _entries.find(e => e.id === entryId);
if (!entry) return;
const typ = TYPEN[entry.typ] || TYPEN.eintrag;
const isMile = entry.is_milestone || entry.typ === 'meilenstein';
const tags = (entry.tags || []);
const photo = entry.media_url
? _mediaHtml(entry.media_url, 'margin-bottom:var(--space-4)')
: '';
// Hunde-Anzeige wenn mehrere beteiligt
const dogIds = entry.dog_ids || [entry.dog_id];
const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs">
${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="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(dog.name)}</span>
</div>` : '';
}).join('')}
</div>`
: '';
const body = `
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
${photo}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span>
</div>
${entry.location_name ? `
<div class="diary-detail-location">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${_escape(entry.location_name)}</a>` : _escape(entry.location_name)}
</div>` : ''}
${dogsHtml}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-3)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="detail-edit">Bearbeiten</button>
`;
UI.modal.open({ title: entry.titel || typ.label, body });
document.getElementById('detail-edit')?.addEventListener('click', async () => {
UI.modal.close();
// Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag)
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);
}
}
});
}
// ----------------------------------------------------------
// 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="${_escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(d.name)}</span>
</label>`).join('')}
</div>
</div>` : '';
const body = `
<form id="diary-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Typ</label>
<select class="form-control" name="typ">${typOpts}</select>
</div>
<div class="form-group">
<label class="form-label">Datum</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
<div class="form-group">
<label class="form-label">Titel <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="titel"
value="${_escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div>
<div class="form-group">
<label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
</div>
<div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
<div style="position:relative">
<div id="diary-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:220px;background:var(--c-surface-2)"></div>
<button type="button" id="diary-map-edit-btn" class="btn btn-secondary btn-sm"
style="position:absolute;bottom:var(--space-2);right:var(--space-2);z-index:500">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
<span id="diary-map-edit-label">Position ändern</span>
</button>
</div>
<!-- POI-Name + Aktionen -->
<div style="margin-top:var(--space-2)">
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-label">${_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 flex-1" 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">${entry?.gps_lat ? 'POI suchen' : 'GPS → 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">
<input type="checkbox" name="is_milestone" id="diary-milestone-cb"
${entry?.is_milestone ? 'checked' : ''} style="display:none">
<button type="button" id="diary-milestone-btn"
class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
<span>${entry?.is_milestone ? 'Meilenstein ✓' : 'Als Meilenstein markieren'}</span>
</button>
</div>
<div class="form-group">
<label class="form-label">Foto / Video <span style="color:var(--c-text-secondary)">(optional)</span></label>
${isEdit && entry.media_url ? `
<div id="diary-current-media" style="position:relative;margin-bottom:var(--space-2)">
${_mediaHtml(entry.media_url, 'max-height:200px;object-fit:cover')}
<button type="button" class="btn btn-danger btn-sm" id="diary-media-delete"
style="position:absolute;top:var(--space-2);right:var(--space-2)">
${UI.icon('trash')}
</button>
</div>
` : ''}
<!-- versteckte Inputs -->
<input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none">
<input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none">
<!-- Auswahlbuttons — immer sichtbar -->
<div id="diary-media-btns" class="diary-media-picker">
<button type="button" class="diary-media-pick-btn" id="diary-btn-camera">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
Kamera
</button>
<button type="button" class="diary-media-pick-btn" id="diary-btn-library">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
Mediathek
</button>
<button type="button" class="diary-media-pick-btn" id="diary-btn-file">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#folder-open"></use></svg>
Datei
</button>
</div>
<div id="diary-media-preview" style="display:none;margin-top:var(--space-2);position:relative">
<img id="diary-photo-preview" style="display:none;width:100%;max-height:200px;object-fit:cover;border-radius:var(--radius-md)">
<video id="diary-video-preview" style="display:none;width:100%;max-height:200px;border-radius:var(--radius-md)" controls playsinline></video>
<button type="button" id="diary-preview-clear"
style="position:absolute;top:var(--space-2);right:var(--space-2)"
class="btn btn-danger btn-sm">${UI.icon('x')}</button>
</div>
</div>
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="diary-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
<div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="diary-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
</div>
</div>
`;
UI.modal.open({ title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag', body, footer });
const form = document.getElementById('diary-form');
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// Media-Inputs + Vorschau
const mediaInput = document.getElementById('diary-media-input');
const cameraInput = document.getElementById('diary-camera-input');
const photoPreview = document.getElementById('diary-photo-preview');
const videoPreview = document.getElementById('diary-video-preview');
const previewWrap = document.getElementById('diary-media-preview');
const mediaBtns = document.getElementById('diary-media-btns');
function _showPreview(file) {
if (!file) return;
previewWrap.style.display = '';
if (file.type.startsWith('video/')) {
photoPreview.style.display = 'none';
videoPreview.style.display = '';
videoPreview.src = URL.createObjectURL(file);
} else {
videoPreview.style.display = 'none';
photoPreview.style.display = '';
photoPreview.src = URL.createObjectURL(file);
}
}
mediaInput?.addEventListener('change', () => _showPreview(mediaInput.files[0]));
cameraInput?.addEventListener('change', () => {
// Auswahl in mediaInput spiegeln damit Submit-Handler nur einen Ort abfragt
const dt = new DataTransfer();
if (cameraInput.files[0]) dt.items.add(cameraInput.files[0]);
mediaInput.files = dt.files;
_showPreview(cameraInput.files[0]);
});
document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click());
document.getElementById('diary-btn-library')?.addEventListener('click', () => {
// Kein capture → iOS zeigt Mediathek-Auswahl, Android zeigt Galerie
const tmp = document.createElement('input');
tmp.type = 'file'; tmp.accept = 'image/*,video/*'; tmp.style.display = 'none';
tmp.addEventListener('change', () => {
const dt = new DataTransfer();
if (tmp.files[0]) dt.items.add(tmp.files[0]);
mediaInput.files = dt.files;
_showPreview(tmp.files[0]);
tmp.remove();
});
document.body.appendChild(tmp);
tmp.click();
});
document.getElementById('diary-btn-file')?.addEventListener('click', () => {
mediaInput.removeAttribute('accept');
mediaInput.click();
mediaInput.setAttribute('accept', 'image/*,video/*');
});
document.getElementById('diary-preview-clear')?.addEventListener('click', () => {
previewWrap.style.display = 'none';
photoPreview.src = ''; videoPreview.src = '';
mediaInput.value = '';
});
// "Entfernen"-Button löscht Medium direkt
document.getElementById('diary-media-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: `${_isVideo(entry.media_url) ? 'Video' : 'Foto'} entfernen?`,
message: 'Das Medium wird dauerhaft gelöscht.',
confirmText: 'Entfernen', danger: true,
});
if (!ok) return;
try {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
const mediaDiv = document.getElementById('diary-current-media');
if (mediaDiv) mediaDiv.remove();
const replaceBtn = document.getElementById('diary-media-replace');
if (replaceBtn) replaceBtn.remove();
mediaInput.style.display = '';
UI.toast.success('Medium entfernt.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
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 = 'GPS → 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
_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="${_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>${_escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
el.addEventListener('click', () => _setName(el.dataset.name));
});
}
sugEl.style.display = '';
} catch (err) {
UI.toast.error(err?.message?.includes('GPS') || lat == null
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
} finally {
UI.setLoading(btn, false);
}
}
document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions);
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Eintrag löschen?',
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
confirmText: 'Löschen',
danger: true,
});
if (ok) await _deleteEntry(entry.id);
});
// Checked-Klasse auf Dog-Picker-Items toggeln
form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.diary-dog-pick-item').classList.toggle('checked', cb.checked);
});
});
form.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="diary-form"][type="submit"]') || form.querySelector('[type="submit"]');
const fd = UI.formData(form);
// dog_ids zusammenbauen: aktiver Hund + gewählte weitere
const dogIds = [_appState.activeDog.id];
form.querySelectorAll('.diary-dog-pick-item input:checked').forEach(cb => {
const id = parseInt(cb.value);
if (!dogIds.includes(id)) dogIds.push(id);
});
await UI.asyncButton(submitBtn, async () => {
const payload = {
datum: fd.datum || null,
typ: fd.typ,
titel: fd.titel || null,
text: fd.text || null,
is_milestone: 'is_milestone' in fd,
dog_ids: dogIds,
gps_lat: _locLat,
gps_lon: _locLon,
location_name: _locName,
};
const mediaFile = mediaInput?.files[0];
if (isEdit) {
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload);
if (mediaFile) {
try {
const fd2 = new FormData();
fd2.append('file', mediaFile);
const media = await API.diary.uploadMedia(_appState.activeDog.id, entry.id, fd2);
updated.media_url = media.media_url;
} catch {
UI.toast.warning('Gespeichert, Medium konnte nicht hochgeladen werden.');
}
}
_updateEntryInList(updated);
UI.toast.success('Eintrag gespeichert.');
} else {
const created = await API.diary.create(_appState.activeDog.id, payload);
if (mediaFile) {
try {
const fd2 = new FormData();
fd2.append('file', mediaFile);
const media = await API.diary.uploadMedia(_appState.activeDog.id, created.id, fd2);
created.media_url = media.media_url;
} catch {
UI.toast.warning('Eintrag erstellt, Medium konnte nicht hochgeladen werden.');
}
}
_entries.unshift(created);
UI.toast.success('Eintrag erstellt.');
}
UI.modal.close();
_renderList();
});
});
}
// ----------------------------------------------------------
// EINTRAG LÖSCHEN
// ----------------------------------------------------------
async function _deleteEntry(entryId) {
try {
await API.diary.delete(_appState.activeDog.id, entryId);
_entries = _entries.filter(e => e.id !== entryId);
UI.modal.close();
_renderList();
UI.toast.success('Eintrag gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _updateEntryInList(updated) {
const i = _entries.findIndex(e => e.id === updated.id);
if (i !== -1) _entries[i] = updated;
}
function _formatMonth(yearMonth) {
const [y, m] = yearMonth.split('-');
return new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+y, +m - 1, 1));
}
function _escape(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// 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>${_escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<label class="import-format-card" id="fmt-nsx">
<input type="radio" name="import-fmt" value="nsx" checked style="display:none">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
</div>
</label>
<label class="import-format-card" id="fmt-csv">
<input type="radio" name="import-fmt" value="csv" style="display:none">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">CSV / Excel</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
</div>
</label>
</div>
<div style="margin-top:var(--space-4)">
<label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer">
</div>
<div id="import-result" style="display:none;margin-top:var(--space-4)"></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="import-start-btn">Importieren</button>`,
});
// Format-Karten klickbar machen
document.querySelectorAll('.import-format-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.import-format-card').forEach(c => c.classList.remove('import-format-card--active'));
card.classList.add('import-format-card--active');
card.querySelector('input[type=radio]').checked = true;
// Accept-Attribut anpassen
const fmt = card.querySelector('input').value;
document.getElementById('import-file-input').accept = fmt === 'nsx' ? '.nsx' : '.csv';
});
});
// Erste Karte direkt aktiv setzen
document.getElementById('fmt-nsx')?.classList.add('import-format-card--active');
document.getElementById('import-start-btn').addEventListener('click', async () => {
const fileInput = document.getElementById('import-file-input');
const fmt = document.querySelector('input[name="import-fmt"]:checked')?.value;
const btn = document.getElementById('import-start-btn');
const resultEl = document.getElementById('import-result');
if (!fileInput.files.length) {
UI.toast('Bitte zuerst eine Datei auswählen.', 'warning');
return;
}
const file = fileInput.files[0];
const dogId = _appState.activeDog?.id;
UI.setLoading(btn, true);
resultEl.style.display = 'none';
try {
const res = fmt === 'nsx'
? await API.importData.notestation(dogId, file)
: await API.importData.csv(dogId, file);
const errHtml = res.errors?.length
? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${_escape(res.errors.join('\n'))}</pre></details>`
: '';
resultEl.innerHTML = `
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)">
<strong>${res.imported} Einträge importiert</strong>
${res.skipped ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
${errHtml}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
// Diary neu laden falls etwas importiert wurde
if (res.imported > 0) {
_offset = 0;
_entries = [];
await _load();
_renderList();
}
} catch (e) {
resultEl.innerHTML = `
<div style="background:var(--c-danger-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-danger)">
Fehler: ${_escape(e.message || String(e))}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
}
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew, onDogChange };
})();