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).
This commit is contained in:
rene 2026-06-14 20:22:35 +02:00
parent 203da50e1d
commit e86d89f3d9
12 changed files with 947 additions and 59 deletions

View file

@ -305,6 +305,34 @@ window.Page_notes = (() => {
`;
}
// ----------------------------------------------------------
// 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
// ----------------------------------------------------------
@ -342,7 +370,9 @@ window.Page_notes = (() => {
</div>
<!-- Notiztext -->
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>
${note.text ? `<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>` : ''}
${_noteMediaStrip(note)}
<!-- Micro-Badges -->
${microBadges.length ? `
@ -495,7 +525,20 @@ window.Page_notes = (() => {
${note.parent_label
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
<p class="notes-detail-text">${UI.escape(note.text || '')}</p>
${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">
@ -523,6 +566,16 @@ window.Page_notes = (() => {
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));
});
});
}
// ----------------------------------------------------------
@ -587,6 +640,12 @@ window.Page_notes = (() => {
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>
@ -597,40 +656,52 @@ window.Page_notes = (() => {
overlay.innerHTML = _buildContent();
document.body.appendChild(overlay);
const _rebind = () => {
overlay.querySelectorAll('.nc-cat').forEach(btn => {
btn.addEventListener('click', () => {
_selType = btn.dataset.type;
overlay.innerHTML = _buildContent();
_rebind();
overlay.querySelector('#nc-text')?.focus();
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', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
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) { UI.toast.warning('Bitte einen Text eingeben.'); return; }
const btn = overlay.querySelector('#nc-save');
await UI.asyncButton(btn, async () => {
const rb = _rubrik(_selType);
await API.notes.create(_selType, 'standalone', {
text,
parent_label: rb.label,
});
overlay.remove();
_filterType = _selType;
await _reload();
UI.toast.success('Notiz gespeichert.');
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);
};
_rebind();
setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100);
}
// ----------------------------------------------------------
@ -732,6 +803,13 @@ window.Page_notes = (() => {
</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 -->
@ -760,6 +838,12 @@ window.Page_notes = (() => {
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;
@ -796,14 +880,17 @@ window.Page_notes = (() => {
});
});
function _close() { overlay.remove(); }
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) { UI.toast.warning('Notiz darf nicht leer sein.'); return; }
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;
@ -819,6 +906,10 @@ window.Page_notes = (() => {
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();