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:
parent
203da50e1d
commit
e86d89f3d9
12 changed files with 947 additions and 59 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue