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