banyaro/backend/static/js/pages/notes.js
rene e86d89f3d9 Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen
Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.

admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
2026-06-14 20:22:35 +02:00

981 lines
47 KiB
JavaScript

/* ============================================================
BAN YARO — Notizblock
Seiten-Modul: Alle Notizen mit Filter, Suche, Sortierung und KI-Analyse.
============================================================ */
window.Page_notes = (() => {
let _container = null;
let _appState = null;
let _notes = [];
// Aktueller Filter-/Such-Zustand
let _filterType = ''; // '' = alle
let _sortMode = 'newest'; // newest | type | location
let _searchQ = '';
let _searchTimer = null;
// KI-Panel
let _kiOpen = false;
let _kiLoading = false;
let _kiSuggestions = null;
let _kiError = null;
// ----------------------------------------------------------
// Rubrik-Konfiguration
// ----------------------------------------------------------
const RUBRIKEN = [
{ type: '', label: 'Alle', color: 'var(--c-text-muted)', icon: 'note' },
{ type: 'health', label: 'Gesundheit', color: '#e74c3c', icon: 'heart' },
{ type: 'diary', label: 'Tagebuch', color: '#C4843A', icon: 'book-open' },
{ type: 'training_session', label: 'Training', color: '#27ae60', icon: 'target' },
{ type: 'route', label: 'Routen', color: '#2980b9', icon: 'path' },
{ type: 'event', label: 'Events', color: '#8e44ad', icon: 'calendar' },
{ type: 'walk', label: 'Gassi-Treffen',color: '#f39c12', icon: 'paw-print' },
{ type: 'sitting', label: 'Sitting', color: '#16a085', icon: 'house-line' },
{ type: 'erste_hilfe', label: 'Erste Hilfe', color: '#c0392b', icon: 'first-aid' },
{ type: 'trainingsplan', label: 'Trainingsplan',color: '#059669', icon: 'clipboard-text' },
{ type: 'friends', label: 'Freunde', color: '#7c3aed', icon: 'users' },
{ type: 'poison', label: 'Giftköder', color: '#dc2626', icon: 'warning-octagon' },
{ type: 'lost', label: 'Vermisste', color: '#b45309', icon: 'magnifying-glass' },
];
function _rubrik(type) {
return RUBRIKEN.find(r => r.type === type) || { type, label: type, color: 'var(--c-text-muted)', icon: 'note' };
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _formatTime(isoStr) {
if (!isoStr) return '';
try {
const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z'));
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch (_) { return ''; }
}
function _dateGroup(isoStr) {
if (!isoStr) return 'Älteres';
try {
const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z'));
const now = new Date();
const diffDays = (now - d) / 86400000;
if (diffDays < 1 && d.getDate() === now.getDate()) return 'Heute';
if (diffDays < 7) return 'Diese Woche';
return 'Älteres';
} catch (_) { return 'Älteres'; }
}
function _truncate(str, max = 600) {
// Karten zeigen max 5 Zeilen via CSS-Clamp — Text muss lang genug
// sein dass die Clamp greift. Bei sehr langen Notes: vor Clamp abschneiden
// damit der String nicht riesig in der DOM-Page steht.
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _load() {
const params = {};
if (_filterType) params.parent_type = _filterType;
if (_sortMode !== 'newest') params.sort = _sortMode;
if (_searchQ) params.q = _searchQ;
return await API.notes.getAll(params);
}
// ----------------------------------------------------------
// Filter/Sortierung anwenden (client-seitig falls API alles zurückgibt)
// ----------------------------------------------------------
function _applySort(list) {
const copy = [...list];
if (_sortMode === 'newest') {
copy.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
} else if (_sortMode === 'type') {
copy.sort((a, b) => (a.parent_type || '').localeCompare(b.parent_type || '', 'de'));
} else if (_sortMode === 'location') {
copy.sort((a, b) => (a.location_name || '').localeCompare(b.location_name || '', 'de'));
}
return copy;
}
// ----------------------------------------------------------
// Rendern
// ----------------------------------------------------------
function _render() {
const kiEnabled = _appState?.user?.notes_ki_enabled !== 0;
const sorted = _applySort(_notes);
// Gruppen aufbauen
const groups = { 'Heute': [], 'Diese Woche': [], 'Älteres': [] };
sorted.forEach(n => {
const g = _dateGroup(n.updated_at || n.created_at);
groups[g].push(n);
});
const groupHtml = Object.entries(groups)
.filter(([, items]) => items.length > 0)
.map(([label, items]) => `
<div class="notes-group">
<div class="list-group-header">${UI.escape(label)}</div>
${items.map(_noteCard).join('')}
</div>
`).join('');
_container.innerHTML = `
<div class="notes-page">
<!-- Header -->
<div class="notes-header">
<h2 class="notes-title">Notizblock</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span class="notes-count">${_notes.length} Notiz${_notes.length !== 1 ? 'en' : ''}</span>
<button id="notes-new-btn" class="btn btn-primary btn-sm" style="gap:4px">
<svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#plus"></use></svg>
${_filterType && _filterType !== '' ? _rubrik(_filterType).label : 'Neue Notiz'}
</button>
</div>
</div>
<!-- Datenschutz-Hinweis (einmalig, wegklickbar) -->
${localStorage.getItem('by_notes_privacy_ack') ? '' : `
<div id="notes-privacy-notice" style="font-size:var(--text-xs);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-3);display:flex;align-items:center;
gap:var(--space-2);cursor:pointer;border:1px solid var(--c-border-light)"
title="Klicken zum Schließen">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#lock-simple"></use></svg>
<span>Alle Notizen sind privat — nur du kannst sie lesen.</span>
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;margin-left:auto;flex-shrink:0;opacity:0.4">
<use href="/icons/phosphor.svg#x"></use></svg>
</div>`}
<!-- KI-Panel -->
${kiEnabled ? _kiPanelHtml() : ''}
<!-- Filter-Chips -->
<div class="notes-filter-chips">
${RUBRIKEN.map(r => `
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}"
data-type="${UI.escape(r.type)}"
style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
${UI.escape(r.label)}
</button>
`).join('')}
</div>
<!-- Suche + Sortierung -->
<div class="notes-toolbar">
<div class="notes-search-wrap">
<svg class="ph-icon notes-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input id="notes-search" type="search" class="notes-search-input"
placeholder="Suche…" value="${UI.escape(_searchQ)}">
</div>
<div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
data-sort="newest">Neueste</button>
<button class="notes-sort-btn ${_sortMode === 'type' ? 'notes-sort-btn--active' : ''}"
data-sort="type">Rubrik</button>
<button class="notes-sort-btn ${_sortMode === 'location' ? 'notes-sort-btn--active' : ''}"
data-sort="location">Ort</button>
</div>
</div>
<!-- Liste -->
<div class="notes-list">
${sorted.length === 0
? UI.emptyState({ icon: 'note', title: 'Keine Notizen', text: 'Füge Notizen zu Trainingseinheiten oder anderen Einträgen hinzu.' })
: groupHtml
}
</div>
</div>
<style>
.notes-page { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.notes-header { display: flex; align-items: center; justify-content: space-between; }
.notes-title { font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--c-text); margin: 0; }
.notes-count { font-size: var(--text-xs); color: var(--c-text-muted); }
/* KI-Panel */
.notes-ki-panel { background: var(--c-surface-2); border: 1.5px solid var(--c-border); border-radius: var(--radius-lg); overflow: hidden; }
.notes-ki-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-4); cursor: pointer; gap: var(--space-2); }
.notes-ki-header-left { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); }
.notes-ki-chevron { transition: transform .2s; color: var(--c-text-muted); }
.notes-ki-chevron--open { transform: rotate(180deg); }
.notes-ki-body { padding: var(--space-3) var(--space-4) var(--space-4); border-top: 1px solid var(--c-border); }
.notes-ki-btn { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); border: none; background: var(--c-primary); color: #fff; font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
.notes-ki-btn:disabled { opacity: .6; cursor: default; }
.notes-ki-suggestions { margin-top: var(--space-3); font-size: var(--text-sm); color: var(--c-text); line-height: 1.6; }
.notes-ki-suggestions ul { margin: var(--space-2) 0 0; padding-left: var(--space-4); }
.notes-ki-suggestions li { margin-bottom: var(--space-1); }
.notes-ki-error { margin-top: var(--space-2); font-size: var(--text-sm); color: var(--c-danger); }
/* Filter-Chips */
.notes-filter-chips { display: flex; gap: var(--space-2); overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.notes-filter-chips::-webkit-scrollbar { display: none; }
@media (min-width: 1024px) {
.notes-filter-chips { flex-wrap: wrap; overflow-x: visible; }
}
.notes-chip { flex-shrink: 0; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 4px var(--space-3); border-radius: 999px; border: 1.5px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-secondary); cursor: pointer; white-space: nowrap; transition: background .15s, color .15s, border-color .15s; }
.notes-chip--active { background: var(--chip-color, var(--c-primary)); color: #fff; border-color: var(--chip-color, var(--c-primary)); }
/* Toolbar */
.notes-toolbar { display: flex; gap: var(--space-2); align-items: center; }
.notes-search-wrap { position: relative; flex: 1; }
.notes-search-icon { position: absolute; left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--c-text-muted); width: 16px; height: 16px; pointer-events: none; }
.notes-search-input { width: 100%; padding: var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3) + 1.3rem); border: 1.5px solid var(--c-border); border-radius: var(--radius-md); font-size: var(--text-sm); background: var(--c-surface); color: var(--c-text); outline: none; box-sizing: border-box; }
.notes-search-input:focus { border-color: var(--c-primary); }
.notes-sort-btns { display: flex; border: 1.5px solid var(--c-border); border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; }
.notes-sort-btn { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-semibold); border: none; background: var(--c-surface-2); color: var(--c-text-secondary); cursor: pointer; transition: background .15s, color .15s; border-right: 1px solid var(--c-border); }
.notes-sort-btn:last-child { border-right: none; }
.notes-sort-btn--active { background: var(--c-primary); color: #fff; }
/* Gruppen */
.notes-group { display: flex; flex-direction: column; gap: var(--space-2); }
/* TODO nach Migration entfernen: ersetzt durch .list-group-header in lists.css */
.notes-group-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: .05em; padding: var(--space-1) 0; }
/* Karten — Notes-spezifischer Override: vertikales Layout statt horizontalem .list-item-card */
.notes-card { flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-chip */
/* .notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; } */
.notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-meta-row */
/* .notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); } */
/* Notes-Override: Actions in Top-Zeile rechts ausrichten (statt align-self:center bei list-item-actions) */
.notes-card-actions { margin-left: auto; align-self: flex-start; }
/* Notes-Override: Newlines (pre-wrap) + max 5 Zeilen mit "…", Rest in Detail-Modal */
.notes-card-text { line-height: 1.55; white-space: pre-wrap; margin: 0; color: var(--c-text);
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; }
/* Detail-Modal: voller Notiz-Text scrollbar */
.notes-detail-text { white-space: pre-wrap; line-height: 1.6; font-size: var(--text-base);
color: var(--c-text); margin: 0; max-height: 60vh; overflow-y: auto; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-micro-badges / .list-item-micro-badge */
/* .notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); } */
/* .notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); } */
/* TODO nach Migration entfernen: ersetzt durch .list-item-action-btn / --danger */
/* .notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; } */
/* .notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); } */
/* .notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); } */
.notes-list { display: flex; flex-direction: column; gap: var(--space-4); }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
`;
_bindEvents();
}
// ----------------------------------------------------------
// KI-Panel HTML
// ----------------------------------------------------------
function _kiPanelHtml() {
return `
<div class="notes-ki-panel" id="notes-ki-panel">
<div class="notes-ki-header" id="notes-ki-toggle">
<div class="notes-ki-header-left">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
Muster-Analyse
</div>
<svg class="ph-icon notes-ki-chevron ${_kiOpen ? 'notes-ki-chevron--open' : ''}" id="notes-ki-chevron" aria-hidden="true"><use href="/icons/phosphor.svg#caret-down"></use></svg>
</div>
${_kiOpen ? `
<div class="notes-ki-body" id="notes-ki-body">
<button class="notes-ki-btn" id="notes-ki-analyse-btn" ${_kiLoading ? 'disabled' : ''}>
${_kiLoading ? '<svg class="ph-icon" aria-hidden="true" style="animation:spin 1s linear infinite"><use href="/icons/phosphor.svg#spinner-gap"></use></svg> Analysiere…' : 'Analysieren'}
</button>
${_kiError ? `<div class="notes-ki-error"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-circle"></use></svg> ${UI.escape(_kiError)}</div>` : ''}
${_kiSuggestions ? `
<div class="notes-ki-suggestions">
<ul>
${_kiSuggestions.map(s => `<li>${UI.escape(s)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
// ----------------------------------------------------------
// Medien-Helfer: Preview-URL ableiten + Indikator-Strip für die Karte
// ----------------------------------------------------------
function _notePreview(url) {
if (!url) return url;
const dot = url.lastIndexOf('.');
if (dot < 0) return url;
if (/\.(mp4|webm|mov|avi|m4v)$/i.test(url)) return url.slice(0, dot) + '_thumb.jpg';
if (/\.(m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
return url.slice(0, dot) + '_preview.webp';
}
function _noteMediaStrip(note) {
const items = note.media_items || [];
if (!items.length) return '';
const n = { image: 0, video: 0, audio: 0, pdf: 0, file: 0 };
items.forEach(m => { n[m.media_type] = (n[m.media_type] || 0) + 1; });
const parts = [];
if (n.image) parts.push(['image', n.image]);
if (n.video) parts.push(['video-camera', n.video]);
if (n.audio) parts.push(['microphone', n.audio]);
if (n.pdf + n.file) parts.push(['paperclip', n.pdf + n.file]);
if (!parts.length) return '';
return `<div class="notes-media-strip" style="display:flex;align-items:center;gap:12px;margin-top:6px;font-size:12px;color:var(--c-text-secondary)">
${parts.map(([icon, count]) => `<span style="display:inline-flex;align-items:center;gap:4px">${UI.icon(icon)} ${count}</span>`).join('')}
</div>`;
}
// ----------------------------------------------------------
// Notiz-Karte HTML
// ----------------------------------------------------------
function _noteCard(note) {
const rb = _rubrik(note.parent_type);
const meta = note.meta_json || {};
const microBadges = [];
if (meta.erfolgsquote) microBadges.push('🐾'.repeat(meta.erfolgsquote));
if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung);
if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung);
const hasLocation = !!note.location_name;
return `
<div class="list-item-card list-item-card--clickable notes-card" data-id="${note.id}">
<!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
<div class="notes-card-top">
<span class="list-item-chip" style="--chip-color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg>
${UI.escape(rb.label)}
</span>
${note.parent_label
? `<span class="notes-parent-label" title="${UI.escape(note.parent_label)}">${UI.escape(note.parent_label)}</span>`
: ''
}
<div class="list-item-actions notes-card-actions">
<button class="list-item-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
</button>
<button class="list-item-action-btn list-item-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
<!-- Notiztext -->
${note.text ? `<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>` : ''}
${_noteMediaStrip(note)}
<!-- Micro-Badges -->
${microBadges.length ? `
<div class="list-item-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
</div>
` : ''}
<!-- Meta: Zeit + Ort -->
<div class="list-item-meta-row">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg>
${UI.escape(_formatTime(note.updated_at || note.created_at))}
${hasLocation ? `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${UI.escape(note.location_name)}` : ''}
</div>
</div>
`;
}
// ----------------------------------------------------------
// Event-Binding
// ----------------------------------------------------------
function _bindEvents() {
// Datenschutz-Hinweis wegklicken
_container.querySelector('#notes-privacy-notice')?.addEventListener('click', () => {
localStorage.setItem('by_notes_privacy_ack', '1');
_container.querySelector('#notes-privacy-notice')?.remove();
});
// Neue Notiz
_container.querySelector('#notes-new-btn')?.addEventListener('click', () => {
_openCreateModal(_filterType || '');
});
// Filter-Chips
_container.querySelectorAll('.notes-chip').forEach(btn => {
btn.addEventListener('click', () => {
_filterType = btn.dataset.type;
_reload();
});
});
// Sortierung
_container.querySelectorAll('.notes-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
_sortMode = btn.dataset.sort;
_render(); // nur neu rendern, keine API-Last
});
});
// Suche (debounced)
const searchInput = _container.querySelector('#notes-search');
if (searchInput) {
searchInput.addEventListener('input', () => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_searchQ = searchInput.value.trim();
_reload();
}, 300);
});
}
// KI-Toggle
const kiToggle = _container.querySelector('#notes-ki-toggle');
if (kiToggle) {
kiToggle.addEventListener('click', () => {
_kiOpen = !_kiOpen;
_render();
});
}
// KI-Analyse-Button
const kiBtn = _container.querySelector('#notes-ki-analyse-btn');
if (kiBtn) {
kiBtn.addEventListener('click', async () => {
_kiLoading = true;
_kiError = null;
_kiSuggestions = null;
_render();
try {
const res = await API.notes.analyse();
if (res && Array.isArray(res.suggestions)) {
_kiSuggestions = res.suggestions;
} else if (res && res.text) {
_kiSuggestions = res.text.split('\n').filter(Boolean);
} else {
_kiSuggestions = ['Keine Vorschläge verfügbar.'];
}
} catch (err) {
_kiError = err?.message || 'KI-Analyse nicht verfügbar.';
} finally {
_kiLoading = false;
_render();
}
});
}
// Edit-Buttons
_container.querySelectorAll('.notes-edit-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const note = _notes.find(n => n.id === parseInt(btn.dataset.id, 10));
if (note) _openEditModal(note);
});
});
// Delete-Buttons
_container.querySelectorAll('.notes-delete-btn').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const noteId = parseInt(btn.dataset.id, 10);
if (!window.confirm('Notiz wirklich löschen?')) return;
try {
await API.notes.delete(noteId);
_notes = _notes.filter(n => n.id !== noteId);
_render();
UI.toast.success('Notiz gelöscht.');
} catch (_) {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
});
// Karte selbst klickbar → Detail-Modal mit vollem Text
_container.querySelectorAll('.notes-card').forEach(card => {
card.addEventListener('click', e => {
// Klicks auf Action-Buttons nicht doppelt verarbeiten
if (e.target.closest('.list-item-action-btn')) return;
const note = _notes.find(n => n.id === parseInt(card.dataset.id, 10));
if (note) _openDetailModal(note);
});
});
}
// ----------------------------------------------------------
// Detail-Modal: voller Notiz-Text + Meta + Bearbeiten/Löschen
// ----------------------------------------------------------
function _openDetailModal(note) {
const rb = RUBRIKEN.find(r => r.id === note.rubrik) || RUBRIKEN[0];
const meta = (() => { try { return JSON.parse(note.meta || '{}'); } catch { return {}; } })();
const microBadges = [];
if (meta.erfolg) microBadges.push(`🐾 ${meta.erfolg}/5`);
if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung);
if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung);
UI.modal.open({
title: `${UI.icon(rb.icon)} ${UI.escape(rb.label)}`,
body: `
<div class="flex-col-gap-3">
${note.parent_label
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
${note.text ? `<p class="notes-detail-text">${UI.escape(note.text)}</p>` : ''}
${(note.media_items && note.media_items.length) ? `
<div style="display:flex;flex-direction:column;gap:8px">
${note.media_items.map(m => {
if (m.media_type === 'image')
return `<img class="notes-detail-media-img" data-full="${m.url}" src="${_notePreview(m.url)}" alt="" style="width:100%;max-height:320px;object-fit:cover;border-radius:var(--radius-md);cursor:pointer">`;
if (m.media_type === 'video')
return `<video src="${m.url}" controls playsinline style="width:100%;max-height:320px;border-radius:var(--radius-md);background:#000"></video>`;
if (m.media_type === 'audio')
return `<audio controls src="${m.url}" style="width:100%"></audio>`;
return `<a href="${m.url}" target="_blank" rel="noopener" class="btn btn-secondary" style="justify-content:flex-start;gap:8px">${UI.icon('file-text')} ${m.media_type === 'pdf' ? 'PDF öffnen' : 'Datei öffnen'}</a>`;
}).join('')}
</div>` : ''}
${microBadges.length ? `
<div class="list-item-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
</div>` : ''}
<div class="list-item-meta-row" style="margin-top:var(--space-2)">
<svg class="ph-icon icon-sm" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
${UI.escape(_formatTime(note.updated_at || note.created_at))}
${note.location_name
? `<svg class="ph-icon icon-sm" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${UI.escape(note.location_name)}` : ''}
</div>
</div>
`,
footer: `
<div class="flex-gap-2" style="width:100%">
<button class="btn btn-ghost flex-1" id="notes-detail-edit">
${UI.icon('pencil')} Bearbeiten
</button>
<button class="btn btn-secondary" data-modal-close>Schließen</button>
</div>
`,
});
document.getElementById('notes-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_openEditModal(note);
});
// Bild-Thumbnails → Lightbox; Preview→Original-Fallback (CSP-konform)
const _imgItems = (note.media_items || []).filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
document.querySelectorAll('.notes-detail-media-img').forEach(img => {
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
img.addEventListener('click', () => {
const idx = _imgItems.findIndex(it => it.url === img.dataset.full);
UI.lightbox?.show(_imgItems, Math.max(0, idx));
});
});
}
// ----------------------------------------------------------
// Laden + Re-Render
// ----------------------------------------------------------
async function _reload() {
_container.querySelector('.notes-list')?.classList.add('loading');
try {
_notes = await _load();
} catch (_) {
_notes = [];
}
_render();
}
// ----------------------------------------------------------
// Create-Modal — neue Notiz mit vorausgewählter Kategorie
// ----------------------------------------------------------
function _openCreateModal(preselectedType = '') {
const ERSTELL_RUBRIKEN = RUBRIKEN.filter(r => r.type !== ''); // ohne "Alle"
let _selType = preselectedType || ERSTELL_RUBRIKEN[0].type;
const modalId = 'notes-create-modal';
document.getElementById(modalId)?.remove();
const overlay = document.createElement('div');
overlay.id = modalId;
overlay.style.cssText = `position:fixed;inset:0;z-index:9999;display:flex;align-items:flex-end;justify-content:center;background:rgba(0,0,0,0.45)`;
const _buildContent = () => {
const rb = _rubrik(_selType);
return `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:90vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<h3 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">Neue Notiz</h3>
<!-- Kategorie-Auswahl -->
<div class="mb-4">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Kategorie</label>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${ERSTELL_RUBRIKEN.map(r => `
<button class="nc-cat" data-type="${r.type}"
style="font-size:var(--text-xs);font-weight:600;padding:4px var(--space-3);
border-radius:999px;border:1.5px solid ${_selType===r.type ? r.color : 'var(--c-border)'};
background:${_selType===r.type ? r.color+'22' : 'var(--c-surface-2)'};
color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer">
${UI.escape(r.label)}
</button>`).join('')}
</div>
</div>
<!-- Text -->
<div class="mb-4">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="nc-text" rows="5" placeholder="Was möchtest du festhalten…"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5;
box-sizing:border-box"></textarea>
</div>
<!-- Medien -->
<div class="mb-4">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
<div id="nc-media"></div>
</div>
<div class="flex-gap-3">
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>`;
};
overlay.innerHTML = _buildContent();
document.body.appendChild(overlay);
const _media = UI.noteMediaAttacher({ containerId: 'nc-media' });
const _remove = () => { _media.destroy(); overlay.remove(); };
// Kategorie-Wechsel: nur Auswahl + Button-Styles aktualisieren — KEIN
// innerHTML-Rebuild, sonst gingen eingegebener Text & angehängte Medien
// (und eine laufende Sprachaufnahme) verloren.
overlay.querySelectorAll('.nc-cat').forEach(btn => {
btn.addEventListener('click', () => {
_selType = btn.dataset.type;
overlay.querySelectorAll('.nc-cat').forEach(b => {
const r = _rubrik(b.dataset.type);
const active = b.dataset.type === _selType;
b.style.borderColor = active ? r.color : 'var(--c-border)';
b.style.background = active ? r.color + '22' : 'var(--c-surface-2)';
b.style.color = active ? r.color : 'var(--c-text-secondary)';
});
});
});
overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove);
overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); });
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
const text = overlay.querySelector('#nc-text')?.value?.trim();
if (!text && !_media.hasPending()) {
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
return;
}
const btn = overlay.querySelector('#nc-save');
await UI.asyncButton(btn, async () => {
const rb = _rubrik(_selType);
const created = await API.notes.create(_selType, 'standalone', {
text: text || '',
parent_label: rb.label,
});
if (created?.id && _media.hasPending()) {
await _media.uploadAll(created.id, (d, t) => { btn.textContent = `${d}/${t} hochgeladen…`; });
}
_remove();
_filterType = _selType;
await _reload();
UI.toast.success('Notiz gespeichert.');
});
});
setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100);
}
// ----------------------------------------------------------
// Edit-Modal (Bottom-Sheet Stil)
// ----------------------------------------------------------
function _openEditModal(note) {
const meta = note.meta_json || {};
const rb = _rubrik(note.parent_type);
const modalId = 'notes-edit-modal';
document.getElementById(modalId)?.remove();
const overlay = document.createElement('div');
overlay.id = modalId;
overlay.style.cssText = `
position:fixed;inset:0;z-index:9999;
display:flex;align-items:flex-end;justify-content:center;
background:rgba(0,0,0,0.45);
`;
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<!-- Griff -->
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<!-- Kopfzeile -->
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-4)">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);
font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px;
background:${rb.color}22;color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> ${UI.escape(rb.label)}
</span>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten
</h3>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Freitext -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Text</label>
<textarea id="notes-edit-text" rows="5"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5;
box-sizing:border-box">${UI.escape(note.text)}</textarea>
</div>
${note.parent_type === 'training_session' ? `
<!-- Bewertung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung</label>
<div class="flex-gap-2">
${[1,2,3,4,5].map(n => `
<button type="button" class="notes-pfote" data-val="${n}"
style="font-size:1.3rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 9px;cursor:pointer;
background:${(meta.erfolgsquote||0)===n?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${(meta.erfolgsquote||0)===n?'var(--c-primary)':'var(--c-border)'}">🐾</button>
`).join('')}
</div>
</div>
<!-- Umgebung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung</label>
<div class="flex-gap-2">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => `
<button type="button" class="notes-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 10px;cursor:pointer;
background:${meta.umgebung===val?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${meta.umgebung===val?'var(--c-primary)':'var(--c-border)'}">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Stimmung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div class="flex-gap-2">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => `
<button type="button" class="notes-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 10px;cursor:pointer;
background:${meta.hund_stimmung===val?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${meta.hund_stimmung===val?'var(--c-primary)':'var(--c-border)'}">${emoji}</button>
`).join('')}
</div>
</div>
` : ''}
<!-- Medien -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
<div id="notes-edit-media"></div>
</div>
</div>
<!-- Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
<button id="notes-edit-delete" type="button"
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:1.5px solid var(--c-danger);background:none;
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
Löschen
</button>
<button id="notes-edit-cancel" type="button"
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:none;
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<button id="notes-edit-save" type="button"
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
border:none;background:var(--c-primary);
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
Speichern
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const _media = UI.noteMediaAttacher({
containerId: 'notes-edit-media',
noteId: note.id,
existingMedia: note.media_items || [],
});
let selErfolgsquote = meta.erfolgsquote || null;
let selUmgebung = meta.umgebung || null;
let selStimmung = meta.hund_stimmung || null;
function _toggleBtn(group, val, getter, setter) {
overlay.querySelectorAll(`.notes-${group}`).forEach(b => {
const match = (group === 'pfote')
? parseInt(b.dataset.val, 10) === val
: b.dataset.val === val;
b.style.background = match ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
b.style.borderColor = match ? 'var(--c-primary)' : 'var(--c-border)';
});
}
overlay.querySelectorAll('.notes-pfote').forEach(btn => {
btn.addEventListener('click', () => {
const v = parseInt(btn.dataset.val, 10);
selErfolgsquote = selErfolgsquote === v ? null : v;
_toggleBtn('pfote', selErfolgsquote, null, null);
});
});
overlay.querySelectorAll('.notes-umgebung').forEach(btn => {
btn.addEventListener('click', () => {
selUmgebung = selUmgebung === btn.dataset.val ? null : btn.dataset.val;
_toggleBtn('umgebung', selUmgebung, null, null);
});
});
overlay.querySelectorAll('.notes-stimmung').forEach(btn => {
btn.addEventListener('click', () => {
selStimmung = selStimmung === btn.dataset.val ? null : btn.dataset.val;
_toggleBtn('stimmung', selStimmung, null, null);
});
});
function _close() { _media.destroy(); overlay.remove(); }
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close);
// Speichern
overlay.querySelector('#notes-edit-save').addEventListener('click', async () => {
const text = overlay.querySelector('#notes-edit-text').value.trim();
if (!text && !_media.hasPending() && !(note.media_items || []).length) {
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
return;
}
const saveBtn = overlay.querySelector('#notes-edit-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichern…';
const metaObj = {};
if (selErfolgsquote) metaObj.erfolgsquote = selErfolgsquote;
if (selUmgebung) metaObj.umgebung = selUmgebung;
if (selStimmung) metaObj.hund_stimmung = selStimmung;
try {
const updated = await API.notes.update(note.id, {
text,
meta_json: Object.keys(metaObj).length > 0 ? metaObj : null,
});
if (_media.hasPending()) {
const { uploaded } = await _media.uploadAll(note.id, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
updated.media_items = (updated.media_items || []).concat(uploaded);
}
const idx = _notes.findIndex(n => n.id === note.id);
if (idx >= 0) _notes[idx] = updated;
_render();
_close();
UI.toast.success('Notiz aktualisiert.');
} catch (_) {
saveBtn.disabled = false;
saveBtn.textContent = 'Speichern';
UI.toast.error('Speichern fehlgeschlagen.');
}
});
// Löschen
overlay.querySelector('#notes-edit-delete').addEventListener('click', async () => {
if (!window.confirm('Notiz wirklich löschen?')) return;
try {
await API.notes.delete(note.id);
_notes = _notes.filter(n => n.id !== note.id);
_render();
_close();
UI.toast.success('Notiz gelöscht.');
} catch (_) {
UI.toast.error('Löschen fehlgeschlagen.');
}
});
}
// ----------------------------------------------------------
// INIT / REFRESH
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
// Zustand zurücksetzen
_filterType = '';
_sortMode = 'newest';
_searchQ = '';
_kiOpen = false;
_kiLoading = false;
_kiSuggestions = null;
_kiError = null;
_notes = [];
_container.innerHTML = UI.skeleton(3);
try {
_notes = await _load();
} catch (_) {
_notes = [];
}
_render();
}
async function refresh() {
if (!_container) return;
_container.innerHTML = UI.skeleton(3);
try {
_notes = await _load();
} catch (_) {
_notes = [];
}
_render();
}
return { init, refresh };
})();