From 3d628b4b96857c399a431799339c56dd532ad1ea Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Fri, 14 Aug 2026 14:52:14 +0200 Subject: [PATCH] feat(projekt): Anheftungen per Drag & Drop zwischen Unterreitern verschieben Pin-Zeilen in der Ordneransicht sind draggable, die Reiter sind Ablageziele. Ein Drop schickt PATCH /api/projects/:id/pins/:pinId {tab_id}; movePin schreibt project_pins.tab_id um. Verschoben wird nur die Referenz. - Zielebene hat die Aufnahme schon: Quell-Pin loeschen statt an UNIQUE uniq_pin scheitern (merged:true) - Zielreiter serverseitig gegen den Ordner pruefen, sonst 404 - .ptab-n braucht pointer-events:none, sonst flackert die Ablagemarkierung Verifiziert gegen die laufende App inkl. 404- und Merge-Faellen; Testdaten zurueckgesetzt. Doku: Kapitel 10 + Changelog. --- app/db.js | 27 + app/server.js | 8 + app/web/src/main.js | 3389 +++++++++++++++++++------------------ app/web/src/style.css | 10 +- docs/08-changelog.md | 33 + docs/10-projektansicht.md | 53 +- 6 files changed, 1842 insertions(+), 1678 deletions(-) diff --git a/app/db.js b/app/db.js index 431befb..deb0fb4 100644 --- a/app/db.js +++ b/app/db.js @@ -208,6 +208,33 @@ export async function pinRecording(projectId, tabId, recordingId) { export async function unpinRecording(pinId) { await pool.execute('DELETE FROM project_pins WHERE id=?', [pinId]); } +// Einen Pin auf eine andere Ebene DESSELBEN Ordners schieben (Drag&Drop in der +// Projektansicht). Hängt die Aufnahme auf der Zielebene schon, entsteht kein +// zweiter Pin — der Quell-Pin verschwindet stattdessen (Zusammenführen statt +// Duplikat; ein UPDATE würde sonst an UNIQUE uniq_pin scheitern). +export async function movePin(pinId, projectId, tabId) { + const [rows] = await pool.execute( + 'SELECT tab_id, recording_id FROM project_pins WHERE id=? AND project_id=?', [pinId, projectId]); + if (!rows.length) return null; + const src = rows[0]; + const dst = tabId || 0; + // Zielreiter muss zu diesem Ordner gehören (0 = Ordner-Ebene, gibt es immer). + if (dst) { + const [t] = await pool.execute( + 'SELECT id FROM project_tabs WHERE id=? AND project_id=?', [dst, projectId]); + if (!t.length) return null; + } + if (src.tab_id === dst) return { ok: true, moved: false, tab_id: dst }; + const [dup] = await pool.execute( + 'SELECT id FROM project_pins WHERE project_id=? AND tab_id=? AND recording_id=?', + [projectId, dst, src.recording_id]); + if (dup.length) { + await pool.execute('DELETE FROM project_pins WHERE id=?', [pinId]); + return { ok: true, moved: true, merged: true, tab_id: dst, pin_id: dup[0].id }; + } + await pool.execute('UPDATE project_pins SET tab_id=? WHERE id=?', [dst, pinId]); + return { ok: true, moved: true, tab_id: dst, pin_id: pinId }; +} // Wo hängt diese Aufnahme überall? Treibt die Markierung des 📌-Buttons in der // Aufnahmenliste. export async function listPinsForRecording(recId) { diff --git a/app/server.js b/app/server.js index 673f7b1..62d9e83 100644 --- a/app/server.js +++ b/app/server.js @@ -541,6 +541,14 @@ app.post('/api/projects/:id/pins', wrap(async (req, res) => { await db.pinRecording(+req.params.id, +((req.body && req.body.tab_id) || 0), recId); res.json({ ok: true }); })); +// Drag&Drop in der Projektansicht: Pin auf eine andere Ebene desselben Ordners +// ziehen. tab_id 0 = zurück auf die Ordner-Ebene. +app.patch('/api/projects/:id/pins/:pinId', wrap(async (req, res) => { + const tabId = +((req.body && req.body.tab_id) || 0); + const r = await db.movePin(+req.params.pinId, +req.params.id, tabId); + if (!r) return res.status(404).json({ error: 'Anheftung oder Zielreiter nicht gefunden' }); + res.json(r); +})); app.delete('/api/projects/:id/pins/:pinId', wrap(async (req, res) => { await db.unpinRecording(+req.params.pinId); res.json({ ok: true }); diff --git a/app/web/src/main.js b/app/web/src/main.js index bd0d528..876b86b 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -1,1673 +1,1716 @@ -import './style.css'; - -const api = { - async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, - async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, - async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); }, - async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, - async patch(u, b) { const r = await fetch(u, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); } -}; - -const app = document.querySelector('#app'); -const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '', - // search: `q` is what was actually submitted (not what is being typed), so a - // tab switch and back restores the result page instead of an empty form. - searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false, - // Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und - // welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters). - projectId: null, projectTabId: 0 }; - -function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } -function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } - -// Theme: data-theme on , chosen in index.html before first paint (no -// flash); the toggle persists to localStorage. -function applyTheme(t) { - document.documentElement.dataset.theme = t; - localStorage.setItem('aime-theme', t); - const b = document.querySelector('#themetoggle'); - if (b) b.textContent = t === 'dark' ? '☀️' : '🌙'; -} - -function render() { - const theme = document.documentElement.dataset.theme || 'dark'; - app.innerHTML = ` -
-
- -

Aime Anruf-Assistent

-
-
- - ${state.recording ? fmt(Date.now() - state.recStart) : '00:00'} - ${state.recording ? ` - - 🏷 ${state.markers.length}` : ''} -
- - -
-
`; - app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); }); - // Logo + Schriftzug = Heimweg zurück auf die Aufnahmen. - app.querySelector('#brandhome').onclick = () => { - if (state.tab === 'recordings') return; - state.tab = 'recordings'; - render(); - }; - app.querySelector('#themetoggle').onclick = () => - applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); - app.querySelector('#recbtn').onclick = toggleRecord; - bindMarkerInput(); - if (state.recording) startTimerUI(); - ({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab](); -} - -// Header record button: start = defaults only (default source = CallRecMix, -// codec auto, speaker count auto — the old options tab is gone on purpose). -// A live placeholder card appears at the top of the recordings list instantly. -async function toggleRecord() { - const btn = document.querySelector('#recbtn'); - if (!state.recording) { - try { - await api.post('/api/record/start', {}); - state.recording = true; state.recStart = Date.now(); - state.markers = []; state.mkDraft = ''; - render(); - if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } - } catch (err) { toast(err.message, true); } - } else { - if (btn) { btn.disabled = true; } - clearInterval(state.timer); state.timer = null; - try { - const r = await api.post('/api/record/stop', {}); - state.recording = false; - toast('Aufnahme gespeichert – wird transkribiert…'); - state.tab = 'recordings'; render(); - state.lastListSig = null; refreshRecList(); - openRecording(r.id); - } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } - } -} -// ---------- live speaker markers ---------- -// While recording: type the name of whoever is talking, Enter. The server stamps -// the offset; after diarization the name lands on that acoustic speaker. Only -// reliable way to tell 20+ conference participants apart. -function bindMarkerInput() { - const inp = document.querySelector('#mkname'); - if (!inp) return; - inp.oninput = () => { state.mkDraft = inp.value; }; - inp.onkeydown = async (e) => { - if (e.key !== 'Enter') return; - const name = inp.value.trim(); - if (!name) return; - inp.value = ''; state.mkDraft = ''; - try { - const r = await api.post('/api/record/marker', { name }); - state.markers = r.markers || []; - updateMarkerUI(); - toast(`🏷 ${fmt(r.t_ms)} ${name}`); - } catch (err) { toast('Marker fehlgeschlagen: ' + err.message, true); } - }; -} -// Refresh the marker count + chip list in place (no re-render → focus stays in -// the input, so names can be typed back-to-back). -function updateMarkerUI() { - const c = document.querySelector('#mkcount'); - if (c) { c.textContent = '🏷 ' + state.markers.length; c.classList.toggle('ok', !!state.markers.length); } - const chips = document.querySelector('#mkchips'); - if (chips) chips.innerHTML = markerChipsHtml(); - bindMarkerChips(); -} -function markerChipsHtml() { - if (!state.markers.length) return 'noch keine Namen markiert'; - return state.markers.map((m, i) => - `${fmt(m.t_ms)} ${escapeHtml(m.name)}`).join(''); -} -function bindMarkerChips() { - document.querySelectorAll('.mkdel').forEach(b => b.onclick = async (e) => { - e.stopPropagation(); - try { - const r = await api.del('/api/record/marker/' + b.dataset.mk); - state.markers = r.markers || []; - updateMarkerUI(); - } catch (err) { toast(err.message, true); } - }); -} - -// Tick every .rectime element (header + list placeholder) from one interval. -function startTimerUI() { - clearInterval(state.timer); - state.timer = setInterval(() => { - const t = fmt(Date.now() - state.recStart); - document.querySelectorAll('[data-rectime]').forEach(el => el.textContent = t); - }, 500); -} - -// ---------- Bluetooth ---------- -async function viewBluetooth() { - const v = document.querySelector('#view'); - v.innerHTML = ` -
-
- - - -
-
Lade…
-
`; - const listEl = v.querySelector('#btlist'); - const msg = v.querySelector('#btmsg'); - - async function load(devices) { - const d = devices || await api.get('/api/bt/devices'); - if (!d.length) { listEl.innerHTML = '
Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).
'; return; } - listEl.innerHTML = d.map(dev => ` -
-
- ${dev.name || '(unbenannt)'} ${dev.audio ? '🎧' : ''} -
${dev.mac}
-
- ${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'} - ${dev.connected - ? `` - : dev.paired - ? `` - : ``} - ${dev.audio ? `` : ''} -
`).join(''); - listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b)); - } - async function act(a, mac, btn) { - btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…'; - try { - const r = await api.post('/api/bt/' + a, { mac }); - if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok); - else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`); - if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {}); - } - catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); } - await load(); - } - v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; }; - v.querySelector('#refresh').onclick = () => load(); - load().catch(e => listEl.innerHTML = '
' + e.message + '
'); -} - -// ---------- Voices (enrollment) ---------- -async function viewVoices() { - const v = document.querySelector('#view'); - v.innerHTML = ` -
-

Stimme anlernen

-
Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
- - -
-
00:00
- -
-
-
-

Personen

-
Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und - mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
-
Lade…
`; - const listEl = v.querySelector('#vplist'); - async function load() { - try { - const persons = await api.get('/api/persons'); - const opts = persons.map(p => ``).join(''); - listEl.innerHTML = persons.length ? persons.map(p => ` -
-
- 👤 ${escapeHtml(p.display_name)} -
${p.names.map(n => `${escapeHtml(n)}${p.names.length > 1 - ? `` : ''}`).join('')} -
-
${p.voiceprints.length} Stimmprobe${p.voiceprints.length === 1 ? '' : 'n'}${p.last_print - ? ' · letzte ' + new Date(p.last_print).toLocaleDateString('de-DE') : ''}${p.voiceprints.some(x => x.stale) - ? ' · ⚠ andere Modellversion' : ''}
-
- ${persons.length > 1 ? `` : ''} - ${p.voiceprints.length ? `` : ''} - -
`).join('') - : '
Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.
'; - - listEl.querySelectorAll('[data-addname]').forEach(b => b.onclick = async () => { - const name = prompt('Weiterer Name für diese Person:'); - if (!name || !name.trim()) return; - try { - const r = await api.post(`/api/persons/${b.dataset.addname}/names`, { name: name.trim() }); - toast(r.merged ? 'Name gehörte einer anderen Person → zusammengeführt ✓' : 'Name hinzugefügt ✓'); - load(); - } catch (e) { toast(e.message, true); } - }); - listEl.querySelectorAll('[data-delname]').forEach(b => b.onclick = async () => { - try { await api.del('/api/persons/names/' + b.dataset.delname); load(); } - catch (e) { toast(e.message, true); } - }); - listEl.querySelectorAll('[data-merge]').forEach(sel => sel.onchange = async () => { - const fromId = sel.value; if (!fromId) return; - if (fromId === sel.dataset.merge) { sel.value = ''; return; } - if (!confirm('Beide Personen als EIN Mensch führen? Namen und Stimmproben wandern zusammen.')) { sel.value = ''; return; } - try { await api.post(`/api/persons/${sel.dataset.merge}/merge`, { fromId: +fromId }); toast('Zusammengeführt ✓'); load(); } - catch (e) { toast(e.message, true); } - }); - listEl.querySelectorAll('[data-clear]').forEach(b => b.onclick = async () => { - if (!confirm('Alle Stimmproben dieser Person löschen? Die Person und ihre Namen bleiben.')) return; - const p = persons.find(x => x.id === +b.dataset.clear); - for (const vp of p.voiceprints) await api.del('/api/voiceprints/' + vp.id); - load(); - }); - listEl.querySelectorAll('[data-delperson]').forEach(b => b.onclick = async () => { - if (!confirm('Person samt allen Namen und Stimmproben löschen?')) return; - await api.del('/api/persons/' + b.dataset.delperson); load(); - }); - } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } - } - await load(); - const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer'); - if (state.enrolling) startTimerUI(timerEl); - btn.onclick = async () => { - if (!state.enrolling) { - const name = v.querySelector('#vname').value.trim(); - if (!name) { toast('Bitte Namen eingeben', true); return; } - state.enrollName = name; - try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); } - catch (e) { toast(e.message, true); } - } else { - btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer); - try { - const r = await api.post('/api/enroll/stop', { name: state.enrollName }); - state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render(); - } catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); } - } - }; -} - -// ---------- Glossary ---------- -async function viewGlossary() { - const v = document.querySelector('#view'); - v.innerHTML = ` -
-

Begriff hinzufügen

-
Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.
- - - - - - -
-

Glossar

Lade…
`; - const listEl = v.querySelector('#glist'); - let editId = null; // id of the entry currently loaded into the form - const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel'); - function setEdit(g) { - editId = g ? g.id : null; - formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen'; - saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen'; - cancelBtn.style.display = g ? '' : 'none'; - v.querySelector('#gterm').value = g ? g.term : ''; - v.querySelector('#gsound').value = g ? (g.soundalikes || '') : ''; - if (g) v.querySelector('#gterm').focus(); - } - async function load() { - try { - const gs = await api.get('/api/glossary'); - listEl.innerHTML = gs.length ? gs.map(g => ` -
📖 ${escapeHtml(g.term)} - ${g.soundalikes ? `
↔ ${escapeHtml(g.soundalikes)}
` : ''}
- -
`).join('') - : '
Noch keine Begriffe. Oben hinzufügen.
'; - listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); if (editId === +b.dataset.del) setEdit(null); load(); }); - listEl.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => setEdit(gs.find(g => g.id === +b.dataset.edit))); - } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } - } - await load(); - cancelBtn.onclick = () => setEdit(null); - saveBtn.onclick = async () => { - const term = v.querySelector('#gterm').value.trim(); - if (!term) { toast('Bitte Begriff eingeben', true); return; } - const soundalikes = v.querySelector('#gsound').value.trim(); - try { - if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes }); - else await api.post('/api/glossary', { term, soundalikes }); - toast(`„${term}" gespeichert ✓`); - setEdit(null); - load(); - } catch (e) { toast(e.message, true); } - }; -} - -// ---------- Search ---------- -// Two-stage on purpose (see search.js): the hit list is SQL and lands in -// milliseconds, the written answer is a ~30-90 s LLM call that streams in -// afterwards. Never make the user wait on the slow half to see the fast half. -const SEARCH_EXAMPLES = [ - 'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?', - 'Was wurde über die Preiserhöhung besprochen?', - 'Welche Aufgaben hat Markus im Juli übernommen?', -]; - -function viewSearch() { - const v = document.querySelector('#view'); - const hasResults = !!state.searchRes; - v.innerHTML = ` -
- - ${hasResults ? '' : ` -
-
Durchsucht alle Mitschriften. Beispiele:
- ${SEARCH_EXAMPLES.map(e => ``).join('')} -
`} -
-
`; - - const input = v.querySelector('#searchq'); - v.querySelector('#searchform').onsubmit = (e) => { e.preventDefault(); runSearch(input.value.trim()); }; - const clr = v.querySelector('#searchclear'); - if (clr) clr.onclick = () => { state.searchQ = ''; state.searchRes = null; state.searchAnswer = null; viewSearch(); }; - v.querySelectorAll('.example').forEach(b => b.onclick = () => { input.value = b.dataset.ex; runSearch(b.dataset.ex); }); - if (!hasResults) input.focus(); - if (hasResults) renderSearchResults(); -} - -async function runSearch(q) { - if (!q || q.length < 2) return; - state.searchQ = q; - state.searchAnswer = null; - state.searchBusy = true; - const out = document.querySelector('#searchout'); - if (out) out.innerHTML = '
Suche…
'; - document.querySelector('.search-page').classList.add('has-results'); - try { - state.searchRes = await api.get('/api/search?q=' + encodeURIComponent(q)); - } catch (e) { - state.searchRes = null; - if (out) out.innerHTML = '
' + escapeHtml(e.message) + '
'; - state.searchBusy = false; - return; - } - state.searchBusy = false; - renderSearchResults(); - // Only bother the NPU when there is something to summarise. - if (state.searchRes.hits.length) askSearchAnswer(q); -} - -// The written answer. Fired after the hits are already on screen; the box shows -// a live elapsed counter because on this hardware it genuinely takes a while. -async function askSearchAnswer(q) { - const box = document.querySelector('#answerbox'); - if (!box) return; - const t0 = Date.now(); - const tick = setInterval(() => { - const el = box.querySelector('.answer-elapsed'); - if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s'; - }, 1000); - try { - const a = await api.post('/api/search/answer', { q }); - // A late answer to a query the user has already replaced must not overwrite - // the newer result. - if (state.searchQ !== q) return; - state.searchAnswer = a; - renderAnswer(); - } catch (e) { - if (state.searchQ !== q) return; - box.innerHTML = `
Antwort
${escapeHtml(e.message)}
`; - } finally { clearInterval(tick); } -} - -// Highlight the query's content words inside a snippet, the way a search engine -// bolds the matched terms. Escaped first, so the highlight cannot inject markup. -function highlight(text, terms) { - let html = escapeHtml(text); - for (const t of (terms || [])) { - if (t.length < 3) continue; - const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1'); - } - return html; -} - -function renderSearchResults() { - const out = document.querySelector('#searchout'); - if (!out || !state.searchRes) return; - const { hits, terms, range, semantic, total } = state.searchRes; - if (!hits.length) { - out.innerHTML = `
- Keine Treffer für „${escapeHtml(state.searchQ)}". -
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''} - Andere Formulierung oder weniger Wörter versuchen.
`; - return; - } - out.innerHTML = ` -
-
Antwort …
-
-
-
- ${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''} - · ${semantic ? 'Volltext + semantisch' : 'nur Volltext'} -
-
    - ${hits.map(h => ` -
  1. -
    ${escapeHtml(new Date(h.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }))} - · Aufnahme #${h.recording_id}${h.speaker_name ? ' · ' + escapeHtml(h.speaker_name) : ''}
    - ${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id} - bei ${fmt(h.t_start_ms)} -
    ${highlight(h.text, terms)}
    -
  2. `).join('')} -
`; - out.querySelectorAll('.result').forEach(li => li.onclick = (e) => { - e.preventDefault(); - openFromSearch(+li.dataset.rec, +li.dataset.t); - }); -} - -function renderAnswer() { - const box = document.querySelector('#answerbox'); - if (!box || !state.searchAnswer) return; - const { answer, sources } = state.searchAnswer; - box.innerHTML = ` -
Antwort
-
${escapeHtml(answer)}
- ${sources && sources.length ? `
- ${sources.map(s => ``).join('')} -
` : ''}`; - box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t)); -} - -// Jump from a hit into the recording, positioned at the spoken minute. The -// detail pane lives in the recordings tab, so switch there first and wait for -// the player element to exist before seeking. -async function openFromSearch(recId, tMs) { - state.tab = 'recordings'; - state.selectedId = recId; - state.trashOpen = false; - render(); - for (let i = 0; i < 40; i++) { - const p = document.querySelector('#player'); - if (p) { - const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} }; - if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true }); - return; - } - await new Promise(r => setTimeout(r, 100)); - } -} - -// ---------- Modal ---------- -// Kleiner Dialog-Baustein für die Pin-Auswahl. Bewusst kein Framework: Inhalt -// als HTML rein, `onOk` bekommt den Wurzelknoten und kann die Felder auslesen; -// gibt onOk `false` zurück, bleibt der Dialog offen (Validierungsfehler). -function openModal(title, bodyHtml, { okText = 'Speichern', onOk, onReady } = {}) { - const back = document.createElement('div'); - back.className = 'modal-back'; - back.innerHTML = ` - `; - document.body.appendChild(back); - const root = back.querySelector('.modal'); - const close = () => { document.removeEventListener('keydown', onKey); back.remove(); }; - const onKey = (e) => { if (e.key === 'Escape') close(); }; - document.addEventListener('keydown', onKey); - back.onclick = (e) => { if (e.target === back) close(); }; - back.querySelector('[data-cancel]').onclick = close; - const ok = back.querySelector('[data-ok]'); - if (ok && onOk) ok.onclick = async () => { - ok.disabled = true; - try { if (await onOk(root, close) !== false) close(); } - catch (e) { toast(e.message, true); } - finally { ok.disabled = false; } - }; - if (onReady) onReady(root, close); - return { root, close }; -} - -// ---------- Projektansicht ---------- -// Zwei Ebenen: Ordner („Project X") und darin Unterreiter („Projektrelevante -// Aufzeichnungen"). Auf BEIDEN Ebenen lassen sich Aufnahmen anpinnen; ein Pin -// ist nur eine Referenz — die Aufnahme bleibt in der Aufnahmenliste, kann in -// mehreren Projekten hängen und wird beim Entpinnen nicht gelöscht. -function viewProjects() { - const v = document.querySelector('#view'); - if (state.projectId == null) return renderProjectList(v); - return renderProjectDetail(v); -} - -async function renderProjectList(v) { - v.innerHTML = ` -
-
-

Projekte

- -
-
Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.
-
Lade…
-
`; - v.querySelector('#pnew').onclick = async () => { - const name = prompt('Name des Projektordners:', ''); - if (name === null || !name.trim()) return; - try { - const p = await api.post('/api/projects', { name: name.trim() }); - toast(`Ordner „${p.name}" angelegt`); - state.projectId = p.id; state.projectTabId = 0; - viewProjects(); - } catch (e) { toast(e.message, true); } - }; - const listEl = v.querySelector('#plist'); - let ps; - try { ps = await api.get('/api/projects'); } - catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } - if (!ps.length) { listEl.innerHTML = '
Noch keine Projekte. Oben einen Ordner anlegen.
'; return; } - listEl.innerHTML = ps.map(p => ` -
-
📁
-
- ${escapeHtml(p.name)} -
${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter
-
- - -
`).join(''); - listEl.querySelectorAll('.proj-card').forEach(c => c.onclick = (e) => { - if (e.target.closest('[data-ren]') || e.target.closest('[data-del]')) return; - state.projectId = +c.dataset.open; state.projectTabId = 0; - viewProjects(); - }); - listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => { - e.stopPropagation(); - const p = ps.find(x => x.id == b.dataset.ren); - const name = prompt('Ordner umbenennen:', p ? p.name : ''); - if (name === null || !name.trim()) return; - await api.patch('/api/projects/' + b.dataset.ren, { name: name.trim() }); - renderProjectList(v); - }); - listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { - e.stopPropagation(); - const p = ps.find(x => x.id == b.dataset.del); - if (!confirm(`Ordner „${p ? p.name : ''}" löschen?\nUnterreiter und Anheftungen verschwinden mit. Die Aufnahmen selbst bleiben erhalten.`)) return; - await api.del('/api/projects/' + b.dataset.del); - toast('Ordner gelöscht'); - renderProjectList(v); - }); -} - -async function renderProjectDetail(v) { - v.innerHTML = '
Lade…
'; - let p; - try { p = await api.get('/api/projects/' + state.projectId); } - catch (e) { v.innerHTML = '
' + e.message + '
'; return; } - if (!p) { state.projectId = null; return viewProjects(); } - // Der zuletzt gewählte Unterreiter kann gelöscht worden sein → zurück auf die - // Ordner-Ebene, statt eine leere Geisterebene anzuzeigen. - if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0; - const cur = state.projectTabId; - const pins = p.pins.filter(x => x.tab_id === cur); - // Die Ordner-Ebene ist umbenennbar; ohne eigenen Namen heißt sie „Ordner". - const rootName = p.root_tab_name || 'Ordner'; - const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : rootName; - v.innerHTML = ` -
-
- -

📁 ${escapeHtml(p.name)}

- -
-
- - ${p.tabs.map(t => ``).join('')} - -
-
-
- ${escapeHtml(levelName)} - ${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'} - - - ${cur ? '' : ''} - -
-
${pins.length ? pins.map(r => ` -
-
- ${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)} -
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
- ${r.summary ? `
${escapeHtml(r.summary)}
` : ''} -
- -
`).join('') - : '
Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.
'}
-
-
`; - v.querySelector('#pback').onclick = () => { state.projectId = null; state.projectTabId = 0; viewProjects(); }; - v.querySelector('#prename').onclick = async () => { - const name = prompt('Ordner umbenennen:', p.name); - if (name === null || !name.trim()) return; - await api.patch('/api/projects/' + p.id, { name: name.trim() }); - renderProjectDetail(v); - }; - v.querySelectorAll('[data-ptab]').forEach(b => b.onclick = () => { - state.projectTabId = +b.dataset.ptab; - renderProjectDetail(v); - }); - v.querySelector('#ptabnew').onclick = async () => { - const name = prompt('Name des Unterreiters:', ''); - if (name === null || !name.trim()) return; - const t = await api.post(`/api/projects/${p.id}/tabs`, { name: name.trim() }); - state.projectTabId = t.id; - renderProjectDetail(v); - }; - const renBtn = v.querySelector('#ptabren'); - if (renBtn) renBtn.onclick = async () => { - // Auf der Ordner-Ebene ist ein leeres Feld erlaubt: es setzt die - // Beschriftung auf die Vorgabe „Ordner" zurück. - const name = prompt(cur ? 'Unterreiter umbenennen:' : 'Ordner-Ebene umbenennen (leer = „Ordner"):', levelName); - if (name === null || (cur && !name.trim())) return; - await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() }); - renderProjectDetail(v); - }; - const delBtn = v.querySelector('#ptabdel'); - if (delBtn) delBtn.onclick = async () => { - if (!confirm(`Unterreiter „${levelName}" löschen?\nDie dort angehefteten Aufnahmen werden nur gelöst, nicht gelöscht.`)) return; - await api.del(`/api/projects/${p.id}/tabs/${cur}`); - state.projectTabId = 0; - renderProjectDetail(v); - }; - v.querySelector('#ppin').onclick = () => pinPickRecordingDialog(p.id, cur, () => renderProjectDetail(v)); - // Klick auf eine angeheftete Aufnahme → direkt in die Aufnahmen-Ansicht. - v.querySelectorAll('#ppins .item.rec').forEach(it => it.onclick = (e) => { - if (e.target.closest('[data-unpin]')) return; - jumpToRecording(+it.dataset.open); - }); - v.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async (e) => { - e.stopPropagation(); - await api.del(`/api/projects/${p.id}/pins/${b.dataset.unpin}`); - toast('Anheftung gelöst'); - renderProjectDetail(v); - }); -} - -// Aus einer Projekt-Ebene heraus: Aufnahme per Dropdown wählen und anheften. -// Das Filterfeld schreibt die Optionsliste neu — bei dreistelligen Aufnahme- -// zahlen ist Scrollen im Select sonst unbrauchbar. -async function pinPickRecordingDialog(projectId, tabId, done) { - let recs; - try { recs = await api.get('/api/recordings'); } - catch (e) { return toast(e.message, true); } - if (!recs.length) return toast('Es gibt noch keine Aufnahmen.', true); - const opt = r => ``; - openModal('Aufnahme anheften', ` - - - - `, { - okText: '📌 Anheften', - onReady: (root) => { - const sel = root.querySelector('#prec'), f = root.querySelector('#pfilter'); - f.oninput = () => { - const q = f.value.trim().toLowerCase(); - const hits = recs.filter(r => !q || (r.title || ('Aufnahme #' + r.id)).toLowerCase().includes(q)); - sel.innerHTML = hits.map(opt).join(''); - }; - f.focus(); - }, - onOk: async (root) => { - const id = +root.querySelector('#prec').value; - if (!id) { toast('Bitte eine Aufnahme wählen', true); return false; } - await api.post(`/api/projects/${projectId}/pins`, { recording_id: id, tab_id: tabId }); - toast('Angeheftet ✓'); - if (done) done(); - } - }); -} - -// Aus der Aufnahmenliste heraus: Ziel (Ordner + Ebene) wählen. Zeigt zugleich -// alle bestehenden Anheftungen dieser Aufnahme, damit man sie hier auch wieder -// lösen kann. -async function pinToProjectDialog(rec, done) { - let projects, pins; - try { [projects, pins] = await Promise.all([api.get('/api/projects'), api.get(`/api/recordings/${rec.id}/pins`)]); } - catch (e) { return toast(e.message, true); } - const title = rec.title || 'Aufnahme #' + rec.id; - const pinsHtml = () => pins.length ? ` - -
${pins.map(p => ` -
📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}
-
`).join('')}
` : ''; - const m = openModal(`„${escapeHtml(title)}" anheften`, ` - - - - -
${pinsHtml()}
`, { - okText: '📌 Anheften', - onReady: (root) => { - const pp = root.querySelector('#pp'), pt = root.querySelector('#pt'); - // Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen. - const loadTabs = async () => { - if (pp.value === 'new') { pt.innerHTML = ''; return; } - const p = await api.get('/api/projects/' + pp.value); - pt.innerHTML = `` + - p.tabs.map(t => ``).join(''); - }; - pp.onchange = async () => { - if (pp.value === 'new') { - const name = prompt('Name des neuen Projektordners:', ''); - if (name && name.trim()) { - const np = await api.post('/api/projects', { name: name.trim() }); - pp.insertAdjacentHTML('afterbegin', ``); - pp.value = String(np.id); - } else { pp.value = projects.length ? String(projects[0].id) : 'new'; } - } - loadTabs(); - }; - bindUnpin(root); - if (projects.length) loadTabs(); - }, - onOk: async (root) => { - const pid = root.querySelector('#pp').value; - if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; } - await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value }); - toast('Angeheftet ✓'); - if (done) done(); - } - }); - function bindUnpin(root) { - root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => { - await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`); - pins = pins.filter(p => p.pin_id != b.dataset.unpin); - root.querySelector('#pcur').innerHTML = pinsHtml(); - bindUnpin(root); - toast('Anheftung gelöst'); - if (done) done(); - }); - } - return m; -} - -// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der -// Suche, nur ohne Zeitsprung). -function jumpToRecording(id) { - state.tab = 'recordings'; - state.selectedId = id; - state.trashOpen = false; - render(); -} - -// ---------- Recordings ---------- -// Analysis-duration counter. Shows the total wall time of a processing run -// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at), -// frozen to analysis_ms once done. Returns the inner only; callers add the ⏱. -function anaTimeSpan(r) { - if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`; - if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing')) - return `${fmt(Date.now() - r.analysis_started_at)}`; - return ''; -} -function ensureAnaTicker() { - if (state.anaTicker) return; - state.anaTicker = setInterval(() => { - document.querySelectorAll('.ana-time.live').forEach(el => { - const t0 = +el.dataset.anaStart; - if (t0) el.textContent = fmt(Date.now() - t0); - }); - }, 1000); -} - -// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the -// selected recording's detail (with its own audio player). Crucially, the -// while-analysing auto-refresh only rebuilds the LEFT list — it never touches -// the RIGHT detail pane, so you can keep listening to an old recording while a -// new one is being transcribed/analysed (the old bug: the whole view — detail + -// audio element — was re-rendered every 5s, stopping playback). -function viewRecordings() { - const v = document.querySelector('#view'); - // Entering the tab: show only the newest day expanded, the rest collapsed - // (applied in refreshRecList once the day groups are known). - state.resetCollapse = true; - v.innerHTML = ` -
- -
-
← Aufnahme wählen
-
`; - v.querySelector('#trashtoggle').onclick = () => { - state.trashOpen = !state.trashOpen; - state.lastListSig = null; - v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen); - refreshRecList(); - }; - // Draggable splitter: list width between LIST_MIN (the old fixed width) and - // twice that; persisted for all sessions. - const LIST_MIN = 340, LIST_MAX = 680; - const listPane = v.querySelector('.rec-list-pane'); - const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; }; - applyW(+localStorage.getItem('aime-list-width') || LIST_MIN); - v.querySelector('.rec-splitter').onmousedown = (e) => { - e.preventDefault(); - const startX = e.clientX, startW = listPane.getBoundingClientRect().width; - document.body.classList.add('resizing'); - const move = (ev) => applyW(startW + (ev.clientX - startX)); - const up = () => { - document.removeEventListener('mousemove', move); - document.removeEventListener('mouseup', up); - document.body.classList.remove('resizing'); - localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width)); - }; - document.addEventListener('mousemove', move); - document.addEventListener('mouseup', up); - }; - refreshRecList(); - if (state.selectedId != null) openRecording(state.selectedId); -} - -// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh -// timer; safe to run while the right pane holds a playing recording. -async function refreshRecList() { - const listEl = document.querySelector('#reclist'); - if (!listEl) return; // left the tab - if (state.trashOpen) return refreshTrashList(listEl); - let recs, pinCounts = {}; - try { recs = await api.get('/api/recordings'); } - catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } - // Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze - // Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert. - try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; } - // Reschedule the poll ONLY while something is still processing. - const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing'); - const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); }; - try { - if (!recs.length && !state.recording) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } - // Signature of everything the list RENDERS except the live ⏱ seconds (those - // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed, - // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept. - // This is the real fix for "I get pulled away from my recording every 5s": - // the 5s poll now no-ops unless a status/title/todo actually changed. - const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}|${pinCounts[r.id] || 0}`).join('~'); - if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; } - state.lastListSig = sig; - // Preserve the left pane's scroll position across the rebuild. - const scroller = listEl.closest('.rec-list-pane') || listEl; - const savedTop = scroller.scrollTop; - // Group by calendar day (recs come newest-first). Each day is a collapsible - // section; the day header shows the recording count. - const groups = []; - const byDay = {}; - for (const r of recs) { - const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); - if (!byDay[day]) { byDay[day] = []; groups.push(day); } - byDay[day].push(r); - } - // Fresh tab entry: only the newest day (first group, recs are newest-first) - // stays open; manual toggles afterwards are respected until the next entry. - if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; } - // Live placeholder while a recording runs: its own card at the very top. - const liveHtml = state.recording ? ` -
- -
Aufnahme läuft… -
wird nach Stop transkribiert & analysiert
-
${markerChipsHtml()}
- ${fmt(Date.now() - state.recStart)} -
` : ''; - listEl.innerHTML = liveHtml + groups.map(day => { - const collapsed = state.collapsedDates.has(day); - const items = byDay[day].map(r => { const at = anaTimeSpan(r); return ` -
-
- ${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)} -
${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}
- ${r.summary ? `
${escapeHtml(r.summary)}
` : ''} -
- - - -
`; }).join(''); - return ` -
-
▾ ${escapeHtml(day)} ${byDay[day].length}
-
${items}
-
`; - }).join(''); - // Click a row (not one of its buttons) → open in the right pane. - listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => { - if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return; - openRecording(+it.dataset.open); - }); - listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => { - e.stopPropagation(); - const rec = recs.find(r => r.id == b.dataset.pin); - pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); }); - }); - listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => { - e.stopPropagation(); - const rec = recs.find(r => r.id == b.dataset.ren); - await renameRecording(+b.dataset.ren, (rec && rec.title) || ''); - }); - listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { - e.stopPropagation(); - const rec = recs.find(r => r.id == b.dataset.del); - const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del; - if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return; - await api.del('/api/recordings/' + b.dataset.del); - if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; } - toast('In den Papierkorb verschoben'); - state.lastListSig = null; refreshRecList(); - }); - bindMarkerChips(); - listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => { - const day = h.dataset.day; - if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day); - h.closest('.day-group').classList.toggle('collapsed'); - }); - scroller.scrollTop = savedTop; // keep the list where the user had it - ensureAnaTicker(); - scheduleNext(); - } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } -} - -// Rename a recording's title. An empty answer clears the title and hands -// naming back to the LLM on the next analysis (see db.renameRecording). -async function renameRecording(id, current) { - const t = prompt('Titel der Aufnahme:', current || ''); - if (t === null) return false; // cancelled - try { - const r = await api.patch(`/api/recordings/${id}/title`, { title: t }); - state.lastListSig = null; - refreshRecList(); - // Keep the open detail pane in sync without rebuilding it (audio keeps playing). - const h = document.querySelector('#detail h2'); - if (h && state.selectedId == id) { - const badge = h.querySelector('.badge'); - h.textContent = r.title || 'Aufnahme #' + id; - if (badge) h.appendChild(badge); - } - toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt'); - return true; - } catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; } -} - -// Trash pane: same list slot, different contents. Deliberately flat (no day -// grouping) — the interesting order here is "when did I delete it", and the -// remaining days matter more than the recording date. -async function refreshTrashList(listEl) { - let data; - try { data = await api.get('/api/trash'); } - catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } - const items = data.items || []; - if (!items.length) { - listEl.innerHTML = `
Papierkorb ist leer.
Gelöschte Aufnahmen bleiben ${data.days} Tage hier.
`; - return; - } - listEl.innerHTML = `
Wird nach ${data.days} Tagen automatisch endgültig gelöscht.
` + items.map(r => ` -
-
- ${escapeHtml(r.title) || 'Aufnahme #' + r.id} -
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)} - · noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}
-
- - -
`).join(''); - listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => { - await api.post(`/api/trash/${b.dataset.restore}/restore`, {}); - toast('Wiederhergestellt'); - refreshTrashList(listEl); - }); - listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => { - const rec = items.find(r => r.id == b.dataset.purge); - const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge; - if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return; - await api.del('/api/trash/' + b.dataset.purge); - toast('Endgültig gelöscht'); - refreshTrashList(listEl); - }); -} - -async function openRecording(id) { - state.selectedId = id; - const detail = document.querySelector('#detail'); - if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); } - // Highlight the selected row without rebuilding the list (keeps it stable). - document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id)); - detail.innerHTML = '
Lade…
'; - const r = await api.get('/api/recordings/' + id); - const pending = r.analysis_status === 'pending'; - const errored = r.analysis_status === 'error'; - const transcribing = r.status === 'transcribing'; - const transcribeFailed = r.status === 'error'; - const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3 - const spkOrder = Object.keys(scls); // first-appearance order - const names = r.speaker_names || {}; // rawSpeaker -> name - const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1)); - // Speaker segments + turns for the lane visualizer and the live-text box. - const segsBySpk = speakerSegments(r.words || []); - const turns = wordTurns(r.words || []); - const durMs = r.duration_ms || (r.words && r.words.length ? r.words[r.words.length - 1].t_end_ms : 0) || 1; - - // Draggable module blocks; the saved order wins, unknown/new modules append. - // NOT modules: the transcript (own sticky right column) and the speaker-lane - // visualizer (fixed full-width strip under the player, spanning both columns). - const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos', transcript: 'Transkript' }; - const vizHtml = spkOrder.length ? ` -
-
-
- ${spkOrder.map((sp, i) => ` -
- ${escapeHtml(spkLabel(sp, i))} -
${(segsBySpk[sp] || []).map(s => - ``).join('')} -
-
`).join('')} -
-
-
- -
` : ''; - // Live-typed name markers: which cluster each one was resolved to, plus the - // option to add one while listening back (same resolution path) and to re-run - // the assignment after a re-diarization. - const CONF_LBL = { high: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' }; - const markersHtml = ` -

Namensmarker (während der Aufnahme getippt)

- ${(r.markers || []).length ? `` : '
Keine Marker. Tipp: während der Aufnahme Namen eintippen (Feld oben) – oder hier beim Zuhören nachtragen.
'} -
- - - ${(r.markers || []).length ? '' : ''} -
`; - const mods = { - analyse: ` - ${r.summary ? `

${renderSummary(r.summary)}

` : '
Noch keine Zusammenfassung.
'} - ${(r.participants && r.participants.length) ? `

Teilnehmer

` : ''} - ${markersHtml}`, - todos: (r.todos && r.todos.length) ? `` : '
Keine ToDos erkannt.
', - transcript: ` -
- ${r.transcript_clean ? `

Dialog (KI · Sprecher aus Kontext)

${renderDialog(r.transcript_clean)}
` : ''} -
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
-
${renderTurns(r.words, names, scls, spkOrder)}
-
- `, - }; - // Two drop columns; every module (incl. the transcript/table panel) can be - // dragged within AND between them. Layout persists for all recordings. - const defLayout = { main: ['analyse', 'todos'], side: ['transcript'] }; - let layout = null; - try { layout = JSON.parse(localStorage.getItem('aime-detail-layout') || 'null'); } catch {} - if (!layout || !Array.isArray(layout.main) || !Array.isArray(layout.side)) { - // migrate the old single-column order if present - let old = []; - try { old = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {} - layout = { main: old.filter(m => m !== 'transcript'), side: [] }; - } - layout.main = layout.main.filter(m => mods[m] !== undefined); - layout.side = layout.side.filter(m => mods[m] !== undefined && !layout.main.includes(m)); - for (const m of [...defLayout.main, ...defLayout.side]) - if (!layout.main.includes(m) && !layout.side.includes(m)) - (defLayout.side.includes(m) ? layout.side : layout.main).push(m); - - const modHtml = (m) => ` -
-
⠿

${MOD_TITLES[m]}

-
${mods[m]}
-
`; - - detail.innerHTML = ` -
-

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

- - - ${vizHtml} -
- ${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} - ${transcribing ? '
📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).
' : ''} - ${transcribeFailed ? `
Transkription fehlgeschlagen.
` : ''} - ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU)
' : ''} - ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} - ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''} - ${spkOrder.length ? `` : ''} -
-
-
${layout.main.map(modHtml).join('')}
-
${layout.side.map(modHtml).join('')}
-
-
`; - - // Drag & drop across BOTH columns: grab any module by its header, drop it in - // either column at any position; the layout is saved for all recordings. - const cols = [...detail.querySelectorAll('.mod-col')]; - // ":empty" never matches because of template whitespace -> class-driven. - const markEmpty = () => cols.forEach(c => c.classList.toggle('empty', !c.querySelector('.module'))); - markEmpty(); - let dragged = null; - detail.querySelectorAll('.mod-head').forEach(h => { - h.addEventListener('dragstart', (e) => { - dragged = h.closest('.module'); - dragged.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data - }); - h.addEventListener('dragend', () => { - if (!dragged) return; - dragged.classList.remove('dragging'); - dragged = null; - const save = {}; - for (const c of cols) save[c.dataset.col] = [...c.querySelectorAll('.module')].map(m => m.dataset.mod); - localStorage.setItem('aime-detail-layout', JSON.stringify(save)); - markEmpty(); - }); - }); - cols.forEach(col => col.addEventListener('dragover', (e) => { - if (!dragged) return; - e.preventDefault(); - const others = [...col.querySelectorAll('.module:not(.dragging)')]; - const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2); - if (next) col.insertBefore(dragged, next); else col.appendChild(dragged); - markEmpty(); - })); - const renBtn = detail.querySelector('#renametitle'); - if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || ''); - const reBtn = detail.querySelector('#reanalyze'); - if (reBtn) reBtn.onclick = async () => { - // Re-runs speaker separation every time: empty = automatic detection (now - // consolidated, so no more "14 speakers" for a 2-person call), a number pins - // the count. Cancel aborts. - const ans = window.prompt('Sprecher neu erkennen?\n(leer = automatisch erkennen · Zahl = feste Anzahl vorgeben)', ''); - if (ans === null) return; - const numSpeakers = parseInt(ans, 10); - const body = numSpeakers > 0 ? { numSpeakers } : {}; - reBtn.disabled = true; reBtn.textContent = 'trennt neu…'; - try { await api.post('/api/recordings/' + id + '/reanalyze', body); setTimeout(() => openRecording(id), 1500); } - catch (e) { toast(e.message, true); } - }; - const rtBtn = detail.querySelector('#retranscribe'); - if (rtBtn) rtBtn.onclick = async () => { - rtBtn.disabled = true; rtBtn.textContent = 'startet…'; - try { await api.post('/api/recordings/' + id + '/retranscribe', {}); setTimeout(() => openRecording(id), 1500); } - catch (e) { toast(e.message, true); rtBtn.disabled = false; } - }; - ensureAnaTicker(); - // Poll while transcription OR the NPU analysis is still running. The old - // version re-ran openRecording(id) blindly every 5s — a stale closure: after - // switching to ANOTHER recording the timer still fired and yanked the user - // (and the playing audio) back to the analysing one. Now: stop when the - // selection changed, poll ONLY the status while busy (no DOM rebuild), and - // rebuild once when the results are in — restoring the player position. - if (pending || transcribing) { - const pollDetail = async () => { - if (state.selectedId !== id || state.tab !== 'recordings') return; // user moved on - if (!document.querySelector('#detail')) return; // view gone - let cur; - try { cur = await api.get('/api/recordings/' + id); } catch { return; } - if (state.selectedId !== id || state.tab !== 'recordings') return; // re-check after await - const busy = cur.analysis_status === 'pending' || cur.status === 'transcribing'; - if (busy) { setTimeout(pollDetail, 5000); return; } - // Results arrived → one rebuild; keep the audio where the user had it. - const a = document.querySelector('#detail #player'); - const keep = a && !a.paused ? { t: a.currentTime, play: true } : (a ? { t: a.currentTime, play: false } : null); - await openRecording(id); - if (keep && keep.t > 0) { - const na = document.querySelector('#detail #player'); - if (na) { na.currentTime = keep.t; if (keep.play) na.play().catch(() => {}); } - } - }; - setTimeout(pollDetail, 5000); - } - const audio = detail.querySelector('#player'); - // ---- marker actions (seek / add at playhead / delete / re-assign) ---- - detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => { - audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play(); - }); - const mkAt = detail.querySelector('#mkat'), mkAtBtn = detail.querySelector('#mkatbtn'); - const addAt = async () => { - const name = (mkAt.value || '').trim(); - if (!name) return; - try { - await api.post(`/api/recordings/${id}/markers`, { name, t_ms: Math.round(audio.currentTime * 1000) }); - mkAt.value = ''; - await api.post(`/api/recordings/${id}/apply-markers`, {}); - toast(`🏷 ${name} gesetzt & zugeordnet`); - openRecording(id); - } catch (e) { toast(e.message, true); } - }; - if (mkAtBtn) mkAtBtn.onclick = addAt; - if (mkAt) mkAt.onkeydown = (e) => { if (e.key === 'Enter') addAt(); }; - detail.querySelectorAll('[data-mkdel]').forEach(b => b.onclick = async () => { - try { await api.del(`/api/recordings/${id}/markers/${b.dataset.mkdel}`); openRecording(id); } - catch (e) { toast(e.message, true); } - }); - const amk = detail.querySelector('#applymk'); - if (amk) amk.onclick = async () => { - amk.disabled = true; amk.textContent = 'ordne zu…'; - try { - const r2 = await api.post(`/api/recordings/${id}/apply-markers`, {}); - toast(`${r2.named} Sprecher benannt, ${r2.merged} zusammengeführt, ${r2.learned} Stimme(n) gelernt`); - openRecording(id); - } catch (e) { toast(e.message, true); amk.disabled = false; } - }; - const spans = [...detail.querySelectorAll('.w')]; - spans.forEach(s => s.onclick = () => { audio.currentTime = (+s.dataset.start) / 1000 + 0.001; audio.play(); }); - // Whisper (batched) word timestamps run slightly AHEAD of the audio, so a - // pure [start,end] match lit the NEXT word ("eine Zeile weiter") before it was - // spoken. Compensate with a fixed lead offset and highlight the LAST word whose - // start has passed (stays lit through inter-word gaps → no flicker). - // Tune SYNC_MS if the marker still leads (raise) or lags (lower). - const SYNC_MS = 250; - - // ---- View switch: transcript <-> conference-table scene ---- - const switchBtn = detail.querySelector('#viewswitch'); - const tView = detail.querySelector('#table-view'); - const trView = detail.querySelector('#transcript-view'); - const sideTitle = detail.querySelector('#sidetitle'); - const bubble = detail.querySelector('#tbubble'); - const tIdle = detail.querySelector('#tidle'); - const persons = [...detail.querySelectorAll('.tperson')]; - let bubbleTurnIdx = -1; - const applyViewMode = () => { - const table = localStorage.getItem('aime-side-view') === 'table'; - if (tView) tView.classList.toggle('hidden', !table); - if (trView) trView.classList.toggle('hidden', table); - if (sideTitle) sideTitle.textContent = table ? 'Am Tisch' : 'Transkript'; - if (switchBtn) switchBtn.innerHTML = table ? ICON_SHEET : ICON_TABLE; - }; - if (switchBtn) { - switchBtn.onclick = () => { - const now = localStorage.getItem('aime-side-view') === 'table' ? 'text' : 'table'; - localStorage.setItem('aime-side-view', now); - applyViewMode(); - }; - applyViewMode(); - } - // All eyes on the speaker: shift every person's pupils toward the active seat. - const lookAt = (activeSp) => { - const target = persons.find(p => p.dataset.sp === activeSp); - for (const p of persons) { - const eyes = p.querySelector('.teyes'); - if (!eyes) continue; - if (!target || p === target) { eyes.style.transform = 'translate(0, 1px)'; continue; } - const dx = (+target.dataset.x) - (+p.dataset.x); - const dy = (+target.dataset.y) - (+p.dataset.y); - const len = Math.hypot(dx, dy) || 1; - eyes.style.transform = `translate(${(dx / len * 3.2).toFixed(1)}px, ${(dy / len * 2.4).toFixed(1)}px)`; - } - }; - const updateTable = (t) => { - if (!tView || tView.classList.contains('hidden')) return; - let idx = -1; - for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; } - if (idx < 0) return; - const tu = turns[idx]; - if (idx !== bubbleTurnIdx) { - bubbleTurnIdx = idx; - const i = spkOrder.indexOf(tu.sp); - const seats = tableSeats(Math.min(persons.length, spkOrder.length)); - const seat = seats[i >= 0 ? Math.min(i, persons.length - 1) : 0]; - if (bubble && seat) { - // The bubble sits FIXED on the table; only its tail (the "Öffnung") - // wanders to point at whoever is speaking. - bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`; - bubble.style.left = ''; - bubble.style.top = ''; - // Tail x-position along the top/bottom edge = the speaker's seat mapped - // into bubble-local coordinates (bubble is centred, BUBBLE_W wide). - const ax = Math.max(8, Math.min(92, (seat.x - (50 - BUBBLE_W / 2)) / BUBBLE_W * 100)); - bubble.style.setProperty('--ax', ax.toFixed(1) + '%'); - bubble.querySelector('.tb-name').textContent = spkLabel(tu.sp, i >= 0 ? i : 0); - // The full turn as clickable word spans — highlighted + scrolled below. - bubble.querySelector('.tb-text').innerHTML = - tu.words.map(w => `${escapeHtml(w.w)}`).join(' '); - } - if (tIdle) tIdle.classList.add('hidden'); - lookAt(tu.sp); - } - persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused)); - // Word highlight + follow-scroll inside the bubble (like the transcript). - if (bubble && !bubble.classList.contains('hidden')) { - const scroller = bubble.querySelector('.tb-scroll'); - const bws = bubble.querySelectorAll('.bw'); - let act = null; - for (const s of bws) { if (+s.dataset.s <= t) act = s; else break; } - bws.forEach(s => s.classList.toggle('active', s === act)); - if (act && scroller) { - const target = act.offsetTop - scroller.clientHeight / 2 + 10; - if (Math.abs(scroller.scrollTop - target) > 24) scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' }); - } - } - }; - if (bubble) bubble.addEventListener('click', (e) => { - const s = e.target.closest('.bw'); - if (s) { audio.currentTime = (+s.dataset.s) / 1000 + 0.001; audio.play().catch(() => {}); } - }); - - // Lane visualizer wiring: playhead + speaking lane + live-text box. - const lanesEl = detail.querySelector('#lanes'); - const playheadEl = detail.querySelector('#playhead'); - const liveboxEl = detail.querySelector('#livebox'); - let liveTurnIdx = -1; - if (lanesEl) { - // Click inside a LANE seeks; clicks on the label column open the editor. - const seek = (e) => { - if (!e.target.closest('.lane')) return; - const lane = lanesEl.querySelector('.lane'); - if (!lane) return; - const rc = lane.getBoundingClientRect(); - const frac = Math.min(1, Math.max(0, (e.clientX - rc.left) / rc.width)); - audio.currentTime = frac * durMs / 1000; - audio.play().catch(() => {}); - }; - lanesEl.addEventListener('click', seek); - } - // Speaker editor popover: click a lane label -> rename + optionally learn the - // voice (replaces the old separate "Sprecher" module). - const pop = detail.querySelector('#spkpop'); - if (pop && lanesEl) { - let popSp = null; - const popName = pop.querySelector('#spkpop-name'); - lanesEl.querySelectorAll('.lane-label').forEach(l => l.onclick = (e) => { - e.stopPropagation(); - const row = l.closest('.lane-row'); - popSp = row.dataset.sp; - pop.style.top = (row.offsetTop + 30) + 'px'; - pop.classList.remove('hidden'); - const i = spkOrder.indexOf(popSp); - popName.value = names[popSp] || ''; - popName.placeholder = spkLabel(popSp, i >= 0 ? i : 0); - popName.focus(); - }); - pop.querySelector('#spkpop-cancel').onclick = () => pop.classList.add('hidden'); - pop.querySelector('#spkpop-save').onclick = async () => { - const name = popName.value.trim(); - const enroll = pop.querySelector('#spkpop-learn').checked; - const btn = pop.querySelector('#spkpop-save'); - btn.disabled = true; btn.textContent = '…'; - try { - const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: popSp, name, enroll }); - const base = res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert'); - toast(res.relabeled ? base + ' · Name in Zusammenfassung übernommen' : base); - openRecording(id); - } catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; } - }; - } - const updateViz = (t) => { - if (!lanesEl) return; - if (playheadEl) { - const lane = lanesEl.querySelector('.lane'); - if (lane) { - const left = lane.offsetLeft + Math.min(1, Math.max(0, t / durMs)) * lane.offsetWidth; - playheadEl.style.left = left + 'px'; - playheadEl.style.display = 'block'; - } - } - // Which speakers talk right now → light their lane rows. - lanesEl.querySelectorAll('.lane-row').forEach(row => { - const sp = row.dataset.sp; - const on = (segsBySpk[sp] || []).some(s => t >= s.start && t <= s.end); - row.classList.toggle('speaking', on && !audio.paused); - }); - // Live text: last turn whose start has passed (stays through gaps). - if (liveboxEl) { - let idx = -1; - for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; } - if (idx !== liveTurnIdx && idx >= 0) { - liveTurnIdx = idx; - const tu = turns[idx]; - const i = spkOrder.indexOf(tu.sp); - liveboxEl.innerHTML = ` -
- ${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))} - ${escapeHtml(tu.text)} -
`; - } - } - }; - audio.onpause = () => { - if (lanesEl) lanesEl.querySelectorAll('.lane-row.speaking').forEach(r2 => r2.classList.remove('speaking')); - persons.forEach(p => p.classList.remove('talking')); - }; - audio.ontimeupdate = () => { - const t = audio.currentTime * 1000 - SYNC_MS; - let active = null; - for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; } - spans.forEach(s => s.classList.toggle('active', s === active)); - // Follow the active word INSIDE the transcript panel (it scrolls itself now — - // scrollIntoView would also yank the page). - if (active && (!trView || !trView.classList.contains('hidden'))) { - const panel = detail.querySelector('#transcript-panel'); - if (panel) { - const pr = panel.getBoundingClientRect(), ar = active.getBoundingClientRect(); - if (ar.top < pr.top + 40 || ar.bottom > pr.bottom - 40) - panel.scrollTo({ top: panel.scrollTop + (ar.top - pr.top) - panel.clientHeight / 2, behavior: 'smooth' }); - } - } - updateViz(t); - updateTable(t); - }; - // (Speaker renaming lives in the lane-label popover above.) - - // Spacebar toggles play/pause of the open recording (unless typing in a field). - if (state.spaceHandler) document.removeEventListener('keydown', state.spaceHandler); - state.spaceHandler = (e) => { - if (e.code !== 'Space' && e.key !== ' ') return; - const t = e.target, tag = (t.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return; - const a = document.querySelector('#player'); - if (!a) return; - e.preventDefault(); - if (a.paused) a.play(); else a.pause(); - }; - document.addEventListener('keydown', state.spaceHandler); - - // Two-pane layout: scroll the detail pane to its top. On a narrow (stacked) - // screen also bring the detail into view since it sits below the list. - const pane = detail.closest('.rec-detail-pane'); - if (pane) pane.scrollTo({ top: 0, behavior: 'smooth' }); - if (window.matchMedia('(max-width: 800px)').matches) detail.scrollIntoView({ behavior: 'smooth' }); -} - -function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } - -// The section summary comes back as "Abschnittstitel:\nFließtext…" blocks (see -// llm.js analyzeLong). Flat, that reads as one wall of text, so the title lines -// get set in bold. Done at RENDER time on purpose, not stored in the database: -// it applies to every summary that already exists, without re-running a -// 20-minute analysis on 22 recordings. -function renderSummary(text) { - return escapeHtml(text || '').split('\n').map(line => { - const t = line.trim(); - // A heading is a SHORT, STANDALONE line ending in a colon. Both conditions - // matter: prose like "…die Struktur der Projektberichterstattung: kurze - // Einordnung, aktueller Stand…" has the colon mid-line and keeps running, - // and excluding . ! ? stops a full sentence that happens to end in a colon. - return /^[^.!?]{3,90}:$/.test(t) ? `${t}` : line; - }).join('\n'); -} - -// ---- Konferenztisch-Ansicht ---- -// Seat positions (person center, % of scene) per participant count, arranged -// around the rectangular table; each seat knows which bubble-arrow side points -// at it from the table-centre bubble. -const SEAT_LAYOUTS = { - 1: [{ x: 50, y: 84, side: 'bottom' }], - 2: [{ x: 50, y: 14, side: 'top' }, { x: 50, y: 84, side: 'bottom' }], - 3: [{ x: 50, y: 13, side: 'top' }, { x: 26, y: 84, side: 'bottom' }, { x: 74, y: 84, side: 'bottom' }], - 4: [{ x: 50, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }], - 5: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 60, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 60, side: 'left' }], - 6: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 68, y: 86, side: 'bottom' }, { x: 32, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }], -}; -// Bubble width in scene-% (kept in sync with .tbubble in style.css) — needed to -// map a seat's x onto the bubble edge so the tail points AT the speaker. -const BUBBLE_W = 54; -function tableSeats(n) { return SEAT_LAYOUTS[Math.min(6, Math.max(1, n))] || SEAT_LAYOUTS[6]; } - -// Toggle-button icons: three people around a table / a text sheet. -// Stecknadel als SVG statt 📌: ein Farb-Emoji bleibt immer rot, `currentColor` -// lässt sich dagegen einfärben — angeheftet = grün, sonst blass wie die -// übrigen Zeilenknöpfe. -const ICON_PIN = ``; -const ICON_TABLE = ``; -const ICON_SHEET = ``; - -// The scene: rounded table, one person per speaker (their lane colour), a -// speech bubble ON the table whose arrow + border follow whoever talks. -function buildTableScene(spkOrder, scls, names, spkLabel) { - if (!spkOrder.length) return '
Keine Sprecherdaten.
'; - const seats = tableSeats(spkOrder.length); - return ` -
-
- ${spkOrder.slice(0, seats.length).map((sp, i) => { - const s = seats[i]; - return ` -
-
-
-
${escapeHtml(spkLabel(sp, i))}
-
`; - }).join('')} - -
▶ Abspielen — die Runde beginnt zu sprechen.
-
`; -} - -// Per-speaker talk segments for the lane visualizer: consecutive words of the -// same speaker, gaps ≤ 1.2 s bridged (word-level gaps would render as dust). -function speakerSegments(words) { - const by = {}; - for (const w of words) { - if (!w.speaker) continue; - const list = by[w.speaker] || (by[w.speaker] = []); - const last = list[list.length - 1]; - if (last && w.t_start_ms - last.end <= 1200) last.end = Math.max(last.end, w.t_end_ms); - else list.push({ start: w.t_start_ms, end: w.t_end_ms }); - } - return by; -} -// Turn list for the table scene: speaker, joined text AND the single words -// (with start times) so the bubble can highlight word-by-word like the transcript. -function wordTurns(words) { - const turns = []; - for (const w of words) { - const last = turns[turns.length - 1]; - if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; last.words.push({ w: w.word, s: w.t_start_ms }); } - else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms, words: [{ w: w.word, s: w.t_start_ms }] }); - } - return turns; -} -// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3. -function speakerClasses(words) { - const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; - for (const w of words) { if (w.speaker && !(w.speaker in m)) { m[w.speaker] = palette[i % palette.length]; i++; } } - return m; -} -// Render the raw transcript as speaker turns with a NAME header per turn. Each -// word stays its own .w span (click-to-seek + active highlight still work). -function renderTurns(words, names, scls, order) { - if (!words || !words.length) return 'Kein Transkript.'; - const idx = {}; order.forEach((s, i) => idx[s] = i); - const label = sp => (names && names[sp]) || (sp ? 'Sprecher ' + ((idx[sp] ?? 0) + 1) : ''); - let html = '', cur = '\0', buf = []; - const flush = () => { - if (!buf.length) return; - const nm = label(cur === '\0' ? null : cur); - html += `
${nm ? `${escapeHtml(nm)} ` : ''}${buf.join(' ')}
`; - buf = []; - }; - for (const w of words) { - const sp = w.speaker || null; - if (sp !== cur) { flush(); cur = sp; } - buf.push(`${escapeHtml(w.word)}`); - } - flush(); - return html; -} -// Render "Name: Text" lines as a colour-coded dialogue (speakers inferred by -// the LLM from context — not acoustic). -function renderDialog(text) { - const lines = (text || '').split(/\n+/).map(l => l.trim()).filter(Boolean); - const colors = {}; let ci = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; - return lines.map(l => { - const m = l.match(/^([^:]{1,30}):\s*(.*)$/); - if (!m) return `
${escapeHtml(l)}
`; - const spk = m[1].trim(); - if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; } - return `
${escapeHtml(spk)} ${escapeHtml(m[2])}
`; - }).join(''); -} -function analysisBadge(st) { - if (st === 'pending') return '🧠 Analyse…'; - if (st === 'error') return 'KI-Fehler'; - if (st === 'done') return 'KI ✓'; - return ''; -} - -// Der Aufnahmezustand lebt im Server (recorder.js `current`), nicht im Browser. -// Ohne diesen Abgleich zeigt der Recorder nach jedem Neuladen 00:00, obwohl -// parecord weiterläuft — und der Stop-Knopf ist unerreichbar, weil das Kopfband -// dann den Start-Knopf malt. -async function rehydrateRecording() { - try { - const h = await api.get('/api/health'); - if (!h || !h.recording) return; - state.recording = true; - state.recStart = h.startedAt || Date.now(); - state.markers = h.markers || []; - state.mkDraft = ''; - render(); - if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } - } catch (err) { /* Server nicht erreichbar: normale Ansicht, kein Toast */ } -} - -render(); -rehydrateRecording(); +import './style.css'; + +const api = { + async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, + async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, + async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); }, + async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, + async patch(u, b) { const r = await fetch(u, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); } +}; + +const app = document.querySelector('#app'); +const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '', + // search: `q` is what was actually submitted (not what is being typed), so a + // tab switch and back restores the result page instead of an empty form. + searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false, + // Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und + // welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters). + projectId: null, projectTabId: 0 }; + +function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } +function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } + +// Theme: data-theme on , chosen in index.html before first paint (no +// flash); the toggle persists to localStorage. +function applyTheme(t) { + document.documentElement.dataset.theme = t; + localStorage.setItem('aime-theme', t); + const b = document.querySelector('#themetoggle'); + if (b) b.textContent = t === 'dark' ? '☀️' : '🌙'; +} + +function render() { + const theme = document.documentElement.dataset.theme || 'dark'; + app.innerHTML = ` +
+
+ +

Aime Anruf-Assistent

+
+
+ + ${state.recording ? fmt(Date.now() - state.recStart) : '00:00'} + ${state.recording ? ` + + 🏷 ${state.markers.length}` : ''} +
+ + +
+
`; + app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); }); + // Logo + Schriftzug = Heimweg zurück auf die Aufnahmen. + app.querySelector('#brandhome').onclick = () => { + if (state.tab === 'recordings') return; + state.tab = 'recordings'; + render(); + }; + app.querySelector('#themetoggle').onclick = () => + applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); + app.querySelector('#recbtn').onclick = toggleRecord; + bindMarkerInput(); + if (state.recording) startTimerUI(); + ({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab](); +} + +// Header record button: start = defaults only (default source = CallRecMix, +// codec auto, speaker count auto — the old options tab is gone on purpose). +// A live placeholder card appears at the top of the recordings list instantly. +async function toggleRecord() { + const btn = document.querySelector('#recbtn'); + if (!state.recording) { + try { + await api.post('/api/record/start', {}); + state.recording = true; state.recStart = Date.now(); + state.markers = []; state.mkDraft = ''; + render(); + if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } + } catch (err) { toast(err.message, true); } + } else { + if (btn) { btn.disabled = true; } + clearInterval(state.timer); state.timer = null; + try { + const r = await api.post('/api/record/stop', {}); + state.recording = false; + toast('Aufnahme gespeichert – wird transkribiert…'); + state.tab = 'recordings'; render(); + state.lastListSig = null; refreshRecList(); + openRecording(r.id); + } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } + } +} +// ---------- live speaker markers ---------- +// While recording: type the name of whoever is talking, Enter. The server stamps +// the offset; after diarization the name lands on that acoustic speaker. Only +// reliable way to tell 20+ conference participants apart. +function bindMarkerInput() { + const inp = document.querySelector('#mkname'); + if (!inp) return; + inp.oninput = () => { state.mkDraft = inp.value; }; + inp.onkeydown = async (e) => { + if (e.key !== 'Enter') return; + const name = inp.value.trim(); + if (!name) return; + inp.value = ''; state.mkDraft = ''; + try { + const r = await api.post('/api/record/marker', { name }); + state.markers = r.markers || []; + updateMarkerUI(); + toast(`🏷 ${fmt(r.t_ms)} ${name}`); + } catch (err) { toast('Marker fehlgeschlagen: ' + err.message, true); } + }; +} +// Refresh the marker count + chip list in place (no re-render → focus stays in +// the input, so names can be typed back-to-back). +function updateMarkerUI() { + const c = document.querySelector('#mkcount'); + if (c) { c.textContent = '🏷 ' + state.markers.length; c.classList.toggle('ok', !!state.markers.length); } + const chips = document.querySelector('#mkchips'); + if (chips) chips.innerHTML = markerChipsHtml(); + bindMarkerChips(); +} +function markerChipsHtml() { + if (!state.markers.length) return 'noch keine Namen markiert'; + return state.markers.map((m, i) => + `${fmt(m.t_ms)} ${escapeHtml(m.name)}`).join(''); +} +function bindMarkerChips() { + document.querySelectorAll('.mkdel').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + try { + const r = await api.del('/api/record/marker/' + b.dataset.mk); + state.markers = r.markers || []; + updateMarkerUI(); + } catch (err) { toast(err.message, true); } + }); +} + +// Tick every .rectime element (header + list placeholder) from one interval. +function startTimerUI() { + clearInterval(state.timer); + state.timer = setInterval(() => { + const t = fmt(Date.now() - state.recStart); + document.querySelectorAll('[data-rectime]').forEach(el => el.textContent = t); + }, 500); +} + +// ---------- Bluetooth ---------- +async function viewBluetooth() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+
+ + + +
+
Lade…
+
`; + const listEl = v.querySelector('#btlist'); + const msg = v.querySelector('#btmsg'); + + async function load(devices) { + const d = devices || await api.get('/api/bt/devices'); + if (!d.length) { listEl.innerHTML = '
Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).
'; return; } + listEl.innerHTML = d.map(dev => ` +
+
+ ${dev.name || '(unbenannt)'} ${dev.audio ? '🎧' : ''} +
${dev.mac}
+
+ ${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'} + ${dev.connected + ? `` + : dev.paired + ? `` + : ``} + ${dev.audio ? `` : ''} +
`).join(''); + listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b)); + } + async function act(a, mac, btn) { + btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…'; + try { + const r = await api.post('/api/bt/' + a, { mac }); + if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok); + else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`); + if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {}); + } + catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); } + await load(); + } + v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; }; + v.querySelector('#refresh').onclick = () => load(); + load().catch(e => listEl.innerHTML = '
' + e.message + '
'); +} + +// ---------- Voices (enrollment) ---------- +async function viewVoices() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+

Stimme anlernen

+
Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
+ + +
+
00:00
+ +
+
+
+

Personen

+
Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und + mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
+
Lade…
`; + const listEl = v.querySelector('#vplist'); + async function load() { + try { + const persons = await api.get('/api/persons'); + const opts = persons.map(p => ``).join(''); + listEl.innerHTML = persons.length ? persons.map(p => ` +
+
+ 👤 ${escapeHtml(p.display_name)} +
${p.names.map(n => `${escapeHtml(n)}${p.names.length > 1 + ? `` : ''}`).join('')} +
+
${p.voiceprints.length} Stimmprobe${p.voiceprints.length === 1 ? '' : 'n'}${p.last_print + ? ' · letzte ' + new Date(p.last_print).toLocaleDateString('de-DE') : ''}${p.voiceprints.some(x => x.stale) + ? ' · ⚠ andere Modellversion' : ''}
+
+ ${persons.length > 1 ? `` : ''} + ${p.voiceprints.length ? `` : ''} + +
`).join('') + : '
Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.
'; + + listEl.querySelectorAll('[data-addname]').forEach(b => b.onclick = async () => { + const name = prompt('Weiterer Name für diese Person:'); + if (!name || !name.trim()) return; + try { + const r = await api.post(`/api/persons/${b.dataset.addname}/names`, { name: name.trim() }); + toast(r.merged ? 'Name gehörte einer anderen Person → zusammengeführt ✓' : 'Name hinzugefügt ✓'); + load(); + } catch (e) { toast(e.message, true); } + }); + listEl.querySelectorAll('[data-delname]').forEach(b => b.onclick = async () => { + try { await api.del('/api/persons/names/' + b.dataset.delname); load(); } + catch (e) { toast(e.message, true); } + }); + listEl.querySelectorAll('[data-merge]').forEach(sel => sel.onchange = async () => { + const fromId = sel.value; if (!fromId) return; + if (fromId === sel.dataset.merge) { sel.value = ''; return; } + if (!confirm('Beide Personen als EIN Mensch führen? Namen und Stimmproben wandern zusammen.')) { sel.value = ''; return; } + try { await api.post(`/api/persons/${sel.dataset.merge}/merge`, { fromId: +fromId }); toast('Zusammengeführt ✓'); load(); } + catch (e) { toast(e.message, true); } + }); + listEl.querySelectorAll('[data-clear]').forEach(b => b.onclick = async () => { + if (!confirm('Alle Stimmproben dieser Person löschen? Die Person und ihre Namen bleiben.')) return; + const p = persons.find(x => x.id === +b.dataset.clear); + for (const vp of p.voiceprints) await api.del('/api/voiceprints/' + vp.id); + load(); + }); + listEl.querySelectorAll('[data-delperson]').forEach(b => b.onclick = async () => { + if (!confirm('Person samt allen Namen und Stimmproben löschen?')) return; + await api.del('/api/persons/' + b.dataset.delperson); load(); + }); + } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } + } + await load(); + const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer'); + if (state.enrolling) startTimerUI(timerEl); + btn.onclick = async () => { + if (!state.enrolling) { + const name = v.querySelector('#vname').value.trim(); + if (!name) { toast('Bitte Namen eingeben', true); return; } + state.enrollName = name; + try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); } + catch (e) { toast(e.message, true); } + } else { + btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer); + try { + const r = await api.post('/api/enroll/stop', { name: state.enrollName }); + state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render(); + } catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); } + } + }; +} + +// ---------- Glossary ---------- +async function viewGlossary() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+

Begriff hinzufügen

+
Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.
+ + + + + + +
+

Glossar

Lade…
`; + const listEl = v.querySelector('#glist'); + let editId = null; // id of the entry currently loaded into the form + const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel'); + function setEdit(g) { + editId = g ? g.id : null; + formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen'; + saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen'; + cancelBtn.style.display = g ? '' : 'none'; + v.querySelector('#gterm').value = g ? g.term : ''; + v.querySelector('#gsound').value = g ? (g.soundalikes || '') : ''; + if (g) v.querySelector('#gterm').focus(); + } + async function load() { + try { + const gs = await api.get('/api/glossary'); + listEl.innerHTML = gs.length ? gs.map(g => ` +
📖 ${escapeHtml(g.term)} + ${g.soundalikes ? `
↔ ${escapeHtml(g.soundalikes)}
` : ''}
+ +
`).join('') + : '
Noch keine Begriffe. Oben hinzufügen.
'; + listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); if (editId === +b.dataset.del) setEdit(null); load(); }); + listEl.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => setEdit(gs.find(g => g.id === +b.dataset.edit))); + } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } + } + await load(); + cancelBtn.onclick = () => setEdit(null); + saveBtn.onclick = async () => { + const term = v.querySelector('#gterm').value.trim(); + if (!term) { toast('Bitte Begriff eingeben', true); return; } + const soundalikes = v.querySelector('#gsound').value.trim(); + try { + if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes }); + else await api.post('/api/glossary', { term, soundalikes }); + toast(`„${term}" gespeichert ✓`); + setEdit(null); + load(); + } catch (e) { toast(e.message, true); } + }; +} + +// ---------- Search ---------- +// Two-stage on purpose (see search.js): the hit list is SQL and lands in +// milliseconds, the written answer is a ~30-90 s LLM call that streams in +// afterwards. Never make the user wait on the slow half to see the fast half. +const SEARCH_EXAMPLES = [ + 'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?', + 'Was wurde über die Preiserhöhung besprochen?', + 'Welche Aufgaben hat Markus im Juli übernommen?', +]; + +function viewSearch() { + const v = document.querySelector('#view'); + const hasResults = !!state.searchRes; + v.innerHTML = ` +
+ + ${hasResults ? '' : ` +
+
Durchsucht alle Mitschriften. Beispiele:
+ ${SEARCH_EXAMPLES.map(e => ``).join('')} +
`} +
+
`; + + const input = v.querySelector('#searchq'); + v.querySelector('#searchform').onsubmit = (e) => { e.preventDefault(); runSearch(input.value.trim()); }; + const clr = v.querySelector('#searchclear'); + if (clr) clr.onclick = () => { state.searchQ = ''; state.searchRes = null; state.searchAnswer = null; viewSearch(); }; + v.querySelectorAll('.example').forEach(b => b.onclick = () => { input.value = b.dataset.ex; runSearch(b.dataset.ex); }); + if (!hasResults) input.focus(); + if (hasResults) renderSearchResults(); +} + +async function runSearch(q) { + if (!q || q.length < 2) return; + state.searchQ = q; + state.searchAnswer = null; + state.searchBusy = true; + const out = document.querySelector('#searchout'); + if (out) out.innerHTML = '
Suche…
'; + document.querySelector('.search-page').classList.add('has-results'); + try { + state.searchRes = await api.get('/api/search?q=' + encodeURIComponent(q)); + } catch (e) { + state.searchRes = null; + if (out) out.innerHTML = '
' + escapeHtml(e.message) + '
'; + state.searchBusy = false; + return; + } + state.searchBusy = false; + renderSearchResults(); + // Only bother the NPU when there is something to summarise. + if (state.searchRes.hits.length) askSearchAnswer(q); +} + +// The written answer. Fired after the hits are already on screen; the box shows +// a live elapsed counter because on this hardware it genuinely takes a while. +async function askSearchAnswer(q) { + const box = document.querySelector('#answerbox'); + if (!box) return; + const t0 = Date.now(); + const tick = setInterval(() => { + const el = box.querySelector('.answer-elapsed'); + if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s'; + }, 1000); + try { + const a = await api.post('/api/search/answer', { q }); + // A late answer to a query the user has already replaced must not overwrite + // the newer result. + if (state.searchQ !== q) return; + state.searchAnswer = a; + renderAnswer(); + } catch (e) { + if (state.searchQ !== q) return; + box.innerHTML = `
Antwort
${escapeHtml(e.message)}
`; + } finally { clearInterval(tick); } +} + +// Highlight the query's content words inside a snippet, the way a search engine +// bolds the matched terms. Escaped first, so the highlight cannot inject markup. +function highlight(text, terms) { + let html = escapeHtml(text); + for (const t of (terms || [])) { + if (t.length < 3) continue; + const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1'); + } + return html; +} + +function renderSearchResults() { + const out = document.querySelector('#searchout'); + if (!out || !state.searchRes) return; + const { hits, terms, range, semantic, total } = state.searchRes; + if (!hits.length) { + out.innerHTML = `
+ Keine Treffer für „${escapeHtml(state.searchQ)}". +
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''} + Andere Formulierung oder weniger Wörter versuchen.
`; + return; + } + out.innerHTML = ` +
+
Antwort …
+
+
+
+ ${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''} + · ${semantic ? 'Volltext + semantisch' : 'nur Volltext'} +
+
    + ${hits.map(h => ` +
  1. +
    ${escapeHtml(new Date(h.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }))} + · Aufnahme #${h.recording_id}${h.speaker_name ? ' · ' + escapeHtml(h.speaker_name) : ''}
    + ${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id} + bei ${fmt(h.t_start_ms)} +
    ${highlight(h.text, terms)}
    +
  2. `).join('')} +
`; + out.querySelectorAll('.result').forEach(li => li.onclick = (e) => { + e.preventDefault(); + openFromSearch(+li.dataset.rec, +li.dataset.t); + }); +} + +function renderAnswer() { + const box = document.querySelector('#answerbox'); + if (!box || !state.searchAnswer) return; + const { answer, sources } = state.searchAnswer; + box.innerHTML = ` +
Antwort
+
${escapeHtml(answer)}
+ ${sources && sources.length ? `
+ ${sources.map(s => ``).join('')} +
` : ''}`; + box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t)); +} + +// Jump from a hit into the recording, positioned at the spoken minute. The +// detail pane lives in the recordings tab, so switch there first and wait for +// the player element to exist before seeking. +async function openFromSearch(recId, tMs) { + state.tab = 'recordings'; + state.selectedId = recId; + state.trashOpen = false; + render(); + for (let i = 0; i < 40; i++) { + const p = document.querySelector('#player'); + if (p) { + const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} }; + if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true }); + return; + } + await new Promise(r => setTimeout(r, 100)); + } +} + +// ---------- Modal ---------- +// Kleiner Dialog-Baustein für die Pin-Auswahl. Bewusst kein Framework: Inhalt +// als HTML rein, `onOk` bekommt den Wurzelknoten und kann die Felder auslesen; +// gibt onOk `false` zurück, bleibt der Dialog offen (Validierungsfehler). +function openModal(title, bodyHtml, { okText = 'Speichern', onOk, onReady } = {}) { + const back = document.createElement('div'); + back.className = 'modal-back'; + back.innerHTML = ` + `; + document.body.appendChild(back); + const root = back.querySelector('.modal'); + const close = () => { document.removeEventListener('keydown', onKey); back.remove(); }; + const onKey = (e) => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', onKey); + back.onclick = (e) => { if (e.target === back) close(); }; + back.querySelector('[data-cancel]').onclick = close; + const ok = back.querySelector('[data-ok]'); + if (ok && onOk) ok.onclick = async () => { + ok.disabled = true; + try { if (await onOk(root, close) !== false) close(); } + catch (e) { toast(e.message, true); } + finally { ok.disabled = false; } + }; + if (onReady) onReady(root, close); + return { root, close }; +} + +// ---------- Projektansicht ---------- +// Zwei Ebenen: Ordner („Project X") und darin Unterreiter („Projektrelevante +// Aufzeichnungen"). Auf BEIDEN Ebenen lassen sich Aufnahmen anpinnen; ein Pin +// ist nur eine Referenz — die Aufnahme bleibt in der Aufnahmenliste, kann in +// mehreren Projekten hängen und wird beim Entpinnen nicht gelöscht. +function viewProjects() { + const v = document.querySelector('#view'); + if (state.projectId == null) return renderProjectList(v); + return renderProjectDetail(v); +} + +async function renderProjectList(v) { + v.innerHTML = ` +
+
+

Projekte

+ +
+
Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.
+
Lade…
+
`; + v.querySelector('#pnew').onclick = async () => { + const name = prompt('Name des Projektordners:', ''); + if (name === null || !name.trim()) return; + try { + const p = await api.post('/api/projects', { name: name.trim() }); + toast(`Ordner „${p.name}" angelegt`); + state.projectId = p.id; state.projectTabId = 0; + viewProjects(); + } catch (e) { toast(e.message, true); } + }; + const listEl = v.querySelector('#plist'); + let ps; + try { ps = await api.get('/api/projects'); } + catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } + if (!ps.length) { listEl.innerHTML = '
Noch keine Projekte. Oben einen Ordner anlegen.
'; return; } + listEl.innerHTML = ps.map(p => ` +
+
📁
+
+ ${escapeHtml(p.name)} +
${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter
+
+ + +
`).join(''); + listEl.querySelectorAll('.proj-card').forEach(c => c.onclick = (e) => { + if (e.target.closest('[data-ren]') || e.target.closest('[data-del]')) return; + state.projectId = +c.dataset.open; state.projectTabId = 0; + viewProjects(); + }); + listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + const p = ps.find(x => x.id == b.dataset.ren); + const name = prompt('Ordner umbenennen:', p ? p.name : ''); + if (name === null || !name.trim()) return; + await api.patch('/api/projects/' + b.dataset.ren, { name: name.trim() }); + renderProjectList(v); + }); + listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + const p = ps.find(x => x.id == b.dataset.del); + if (!confirm(`Ordner „${p ? p.name : ''}" löschen?\nUnterreiter und Anheftungen verschwinden mit. Die Aufnahmen selbst bleiben erhalten.`)) return; + await api.del('/api/projects/' + b.dataset.del); + toast('Ordner gelöscht'); + renderProjectList(v); + }); +} + +async function renderProjectDetail(v) { + v.innerHTML = '
Lade…
'; + let p; + try { p = await api.get('/api/projects/' + state.projectId); } + catch (e) { v.innerHTML = '
' + e.message + '
'; return; } + if (!p) { state.projectId = null; return viewProjects(); } + // Der zuletzt gewählte Unterreiter kann gelöscht worden sein → zurück auf die + // Ordner-Ebene, statt eine leere Geisterebene anzuzeigen. + if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0; + const cur = state.projectTabId; + const pins = p.pins.filter(x => x.tab_id === cur); + // Die Ordner-Ebene ist umbenennbar; ohne eigenen Namen heißt sie „Ordner". + const rootName = p.root_tab_name || 'Ordner'; + const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : rootName; + v.innerHTML = ` +
+
+ +

📁 ${escapeHtml(p.name)}

+ +
+
+ + ${p.tabs.map(t => ``).join('')} + +
+
+
+ ${escapeHtml(levelName)} + ${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}${p.tabs.length && pins.length ? ' · auf einen Reiter ziehen zum Verschieben' : ''} + + + ${cur ? '' : ''} + +
+
${pins.length ? pins.map(r => ` +
+
+ ${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)} +
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
+ ${r.summary ? `
${escapeHtml(r.summary)}
` : ''} +
+ +
`).join('') + : '
Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.
'}
+
+
`; + v.querySelector('#pback').onclick = () => { state.projectId = null; state.projectTabId = 0; viewProjects(); }; + v.querySelector('#prename').onclick = async () => { + const name = prompt('Ordner umbenennen:', p.name); + if (name === null || !name.trim()) return; + await api.patch('/api/projects/' + p.id, { name: name.trim() }); + renderProjectDetail(v); + }; + v.querySelectorAll('[data-ptab]').forEach(b => b.onclick = () => { + state.projectTabId = +b.dataset.ptab; + renderProjectDetail(v); + }); + v.querySelector('#ptabnew').onclick = async () => { + const name = prompt('Name des Unterreiters:', ''); + if (name === null || !name.trim()) return; + const t = await api.post(`/api/projects/${p.id}/tabs`, { name: name.trim() }); + state.projectTabId = t.id; + renderProjectDetail(v); + }; + const renBtn = v.querySelector('#ptabren'); + if (renBtn) renBtn.onclick = async () => { + // Auf der Ordner-Ebene ist ein leeres Feld erlaubt: es setzt die + // Beschriftung auf die Vorgabe „Ordner" zurück. + const name = prompt(cur ? 'Unterreiter umbenennen:' : 'Ordner-Ebene umbenennen (leer = „Ordner"):', levelName); + if (name === null || (cur && !name.trim())) return; + await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() }); + renderProjectDetail(v); + }; + const delBtn = v.querySelector('#ptabdel'); + if (delBtn) delBtn.onclick = async () => { + if (!confirm(`Unterreiter „${levelName}" löschen?\nDie dort angehefteten Aufnahmen werden nur gelöst, nicht gelöscht.`)) return; + await api.del(`/api/projects/${p.id}/tabs/${cur}`); + state.projectTabId = 0; + renderProjectDetail(v); + }; + v.querySelector('#ppin').onclick = () => pinPickRecordingDialog(p.id, cur, () => renderProjectDetail(v)); + // Klick auf eine angeheftete Aufnahme → direkt in die Aufnahmen-Ansicht. + v.querySelectorAll('#ppins .item.rec').forEach(it => it.onclick = (e) => { + if (e.target.closest('[data-unpin]')) return; + jumpToRecording(+it.dataset.open); + }); + v.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + await api.del(`/api/projects/${p.id}/pins/${b.dataset.unpin}`); + toast('Anheftung gelöst'); + renderProjectDetail(v); + }); + bindPinDrag(v, p, cur, rootName); +} + +// Drag&Drop innerhalb eines Ordners: eine angeheftete Aufnahme auf einen +// anderen Reiter ziehen verschiebt den Pin dorthin. Verschoben wird nur die +// Referenz — die Aufnahme selbst bleibt, wo sie ist. +function bindPinDrag(v, p, cur, rootName) { + let dragPin = null; + const clearMarks = () => v.querySelectorAll('.ptab.drop').forEach(t => t.classList.remove('drop')); + v.querySelectorAll('#ppins .item.rec').forEach(it => { + it.ondragstart = (e) => { + dragPin = it.dataset.pin; + it.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + // Ohne gesetzte Daten startet Firefox den Drag gar nicht erst. + e.dataTransfer.setData('text/plain', it.dataset.pin); + }; + it.ondragend = () => { dragPin = null; it.classList.remove('dragging'); clearMarks(); }; + }); + v.querySelectorAll('.ptab[data-ptab]').forEach(t => { + const target = +t.dataset.ptab; + t.ondragover = (e) => { + if (dragPin == null || target === cur) return; // eigener Reiter = kein Ziel + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + t.classList.add('drop'); + }; + t.ondragleave = () => t.classList.remove('drop'); + t.ondrop = async (e) => { + e.preventDefault(); + clearMarks(); + const pinId = dragPin || e.dataTransfer.getData('text/plain'); + dragPin = null; + if (!pinId || target === cur) return; + const name = target ? (p.tabs.find(x => x.id === target) || {}).name : rootName; + try { + const r = await api.patch(`/api/projects/${p.id}/pins/${pinId}`, { tab_id: target }); + toast(r.merged ? `Hängt dort bereits — Anheftung zusammengeführt` + : `Verschoben nach „${name}"`); + } catch (err) { toast(err.message, true); } + renderProjectDetail(v); + }; + }); +} + +// Aus einer Projekt-Ebene heraus: Aufnahme per Dropdown wählen und anheften. +// Das Filterfeld schreibt die Optionsliste neu — bei dreistelligen Aufnahme- +// zahlen ist Scrollen im Select sonst unbrauchbar. +async function pinPickRecordingDialog(projectId, tabId, done) { + let recs; + try { recs = await api.get('/api/recordings'); } + catch (e) { return toast(e.message, true); } + if (!recs.length) return toast('Es gibt noch keine Aufnahmen.', true); + const opt = r => ``; + openModal('Aufnahme anheften', ` + + + + `, { + okText: '📌 Anheften', + onReady: (root) => { + const sel = root.querySelector('#prec'), f = root.querySelector('#pfilter'); + f.oninput = () => { + const q = f.value.trim().toLowerCase(); + const hits = recs.filter(r => !q || (r.title || ('Aufnahme #' + r.id)).toLowerCase().includes(q)); + sel.innerHTML = hits.map(opt).join(''); + }; + f.focus(); + }, + onOk: async (root) => { + const id = +root.querySelector('#prec').value; + if (!id) { toast('Bitte eine Aufnahme wählen', true); return false; } + await api.post(`/api/projects/${projectId}/pins`, { recording_id: id, tab_id: tabId }); + toast('Angeheftet ✓'); + if (done) done(); + } + }); +} + +// Aus der Aufnahmenliste heraus: Ziel (Ordner + Ebene) wählen. Zeigt zugleich +// alle bestehenden Anheftungen dieser Aufnahme, damit man sie hier auch wieder +// lösen kann. +async function pinToProjectDialog(rec, done) { + let projects, pins; + try { [projects, pins] = await Promise.all([api.get('/api/projects'), api.get(`/api/recordings/${rec.id}/pins`)]); } + catch (e) { return toast(e.message, true); } + const title = rec.title || 'Aufnahme #' + rec.id; + const pinsHtml = () => pins.length ? ` + +
${pins.map(p => ` +
📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}
+
`).join('')}
` : ''; + const m = openModal(`„${escapeHtml(title)}" anheften`, ` + + + + +
${pinsHtml()}
`, { + okText: '📌 Anheften', + onReady: (root) => { + const pp = root.querySelector('#pp'), pt = root.querySelector('#pt'); + // Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen. + const loadTabs = async () => { + if (pp.value === 'new') { pt.innerHTML = ''; return; } + const p = await api.get('/api/projects/' + pp.value); + pt.innerHTML = `` + + p.tabs.map(t => ``).join(''); + }; + pp.onchange = async () => { + if (pp.value === 'new') { + const name = prompt('Name des neuen Projektordners:', ''); + if (name && name.trim()) { + const np = await api.post('/api/projects', { name: name.trim() }); + pp.insertAdjacentHTML('afterbegin', ``); + pp.value = String(np.id); + } else { pp.value = projects.length ? String(projects[0].id) : 'new'; } + } + loadTabs(); + }; + bindUnpin(root); + if (projects.length) loadTabs(); + }, + onOk: async (root) => { + const pid = root.querySelector('#pp').value; + if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; } + await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value }); + toast('Angeheftet ✓'); + if (done) done(); + } + }); + function bindUnpin(root) { + root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => { + await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`); + pins = pins.filter(p => p.pin_id != b.dataset.unpin); + root.querySelector('#pcur').innerHTML = pinsHtml(); + bindUnpin(root); + toast('Anheftung gelöst'); + if (done) done(); + }); + } + return m; +} + +// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der +// Suche, nur ohne Zeitsprung). +function jumpToRecording(id) { + state.tab = 'recordings'; + state.selectedId = id; + state.trashOpen = false; + render(); +} + +// ---------- Recordings ---------- +// Analysis-duration counter. Shows the total wall time of a processing run +// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at), +// frozen to analysis_ms once done. Returns the inner only; callers add the ⏱. +function anaTimeSpan(r) { + if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`; + if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing')) + return `${fmt(Date.now() - r.analysis_started_at)}`; + return ''; +} +function ensureAnaTicker() { + if (state.anaTicker) return; + state.anaTicker = setInterval(() => { + document.querySelectorAll('.ana-time.live').forEach(el => { + const t0 = +el.dataset.anaStart; + if (t0) el.textContent = fmt(Date.now() - t0); + }); + }, 1000); +} + +// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the +// selected recording's detail (with its own audio player). Crucially, the +// while-analysing auto-refresh only rebuilds the LEFT list — it never touches +// the RIGHT detail pane, so you can keep listening to an old recording while a +// new one is being transcribed/analysed (the old bug: the whole view — detail + +// audio element — was re-rendered every 5s, stopping playback). +function viewRecordings() { + const v = document.querySelector('#view'); + // Entering the tab: show only the newest day expanded, the rest collapsed + // (applied in refreshRecList once the day groups are known). + state.resetCollapse = true; + v.innerHTML = ` +
+ +
+
← Aufnahme wählen
+
`; + v.querySelector('#trashtoggle').onclick = () => { + state.trashOpen = !state.trashOpen; + state.lastListSig = null; + v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen); + refreshRecList(); + }; + // Draggable splitter: list width between LIST_MIN (the old fixed width) and + // twice that; persisted for all sessions. + const LIST_MIN = 340, LIST_MAX = 680; + const listPane = v.querySelector('.rec-list-pane'); + const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; }; + applyW(+localStorage.getItem('aime-list-width') || LIST_MIN); + v.querySelector('.rec-splitter').onmousedown = (e) => { + e.preventDefault(); + const startX = e.clientX, startW = listPane.getBoundingClientRect().width; + document.body.classList.add('resizing'); + const move = (ev) => applyW(startW + (ev.clientX - startX)); + const up = () => { + document.removeEventListener('mousemove', move); + document.removeEventListener('mouseup', up); + document.body.classList.remove('resizing'); + localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width)); + }; + document.addEventListener('mousemove', move); + document.addEventListener('mouseup', up); + }; + refreshRecList(); + if (state.selectedId != null) openRecording(state.selectedId); +} + +// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh +// timer; safe to run while the right pane holds a playing recording. +async function refreshRecList() { + const listEl = document.querySelector('#reclist'); + if (!listEl) return; // left the tab + if (state.trashOpen) return refreshTrashList(listEl); + let recs, pinCounts = {}; + try { recs = await api.get('/api/recordings'); } + catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } + // Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze + // Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert. + try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; } + // Reschedule the poll ONLY while something is still processing. + const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing'); + const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); }; + try { + if (!recs.length && !state.recording) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } + // Signature of everything the list RENDERS except the live ⏱ seconds (those + // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed, + // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept. + // This is the real fix for "I get pulled away from my recording every 5s": + // the 5s poll now no-ops unless a status/title/todo actually changed. + const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}|${pinCounts[r.id] || 0}`).join('~'); + if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; } + state.lastListSig = sig; + // Preserve the left pane's scroll position across the rebuild. + const scroller = listEl.closest('.rec-list-pane') || listEl; + const savedTop = scroller.scrollTop; + // Group by calendar day (recs come newest-first). Each day is a collapsible + // section; the day header shows the recording count. + const groups = []; + const byDay = {}; + for (const r of recs) { + const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); + if (!byDay[day]) { byDay[day] = []; groups.push(day); } + byDay[day].push(r); + } + // Fresh tab entry: only the newest day (first group, recs are newest-first) + // stays open; manual toggles afterwards are respected until the next entry. + if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; } + // Live placeholder while a recording runs: its own card at the very top. + const liveHtml = state.recording ? ` +
+ +
Aufnahme läuft… +
wird nach Stop transkribiert & analysiert
+
${markerChipsHtml()}
+ ${fmt(Date.now() - state.recStart)} +
` : ''; + listEl.innerHTML = liveHtml + groups.map(day => { + const collapsed = state.collapsedDates.has(day); + const items = byDay[day].map(r => { const at = anaTimeSpan(r); return ` +
+
+ ${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)} +
${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}
+ ${r.summary ? `
${escapeHtml(r.summary)}
` : ''} +
+ + + +
`; }).join(''); + return ` +
+
▾ ${escapeHtml(day)} ${byDay[day].length}
+
${items}
+
`; + }).join(''); + // Click a row (not one of its buttons) → open in the right pane. + listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => { + if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return; + openRecording(+it.dataset.open); + }); + listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => { + e.stopPropagation(); + const rec = recs.find(r => r.id == b.dataset.pin); + pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); }); + }); + listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + const rec = recs.find(r => r.id == b.dataset.ren); + await renameRecording(+b.dataset.ren, (rec && rec.title) || ''); + }); + listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + const rec = recs.find(r => r.id == b.dataset.del); + const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del; + if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return; + await api.del('/api/recordings/' + b.dataset.del); + if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; } + toast('In den Papierkorb verschoben'); + state.lastListSig = null; refreshRecList(); + }); + bindMarkerChips(); + listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => { + const day = h.dataset.day; + if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day); + h.closest('.day-group').classList.toggle('collapsed'); + }); + scroller.scrollTop = savedTop; // keep the list where the user had it + ensureAnaTicker(); + scheduleNext(); + } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } +} + +// Rename a recording's title. An empty answer clears the title and hands +// naming back to the LLM on the next analysis (see db.renameRecording). +async function renameRecording(id, current) { + const t = prompt('Titel der Aufnahme:', current || ''); + if (t === null) return false; // cancelled + try { + const r = await api.patch(`/api/recordings/${id}/title`, { title: t }); + state.lastListSig = null; + refreshRecList(); + // Keep the open detail pane in sync without rebuilding it (audio keeps playing). + const h = document.querySelector('#detail h2'); + if (h && state.selectedId == id) { + const badge = h.querySelector('.badge'); + h.textContent = r.title || 'Aufnahme #' + id; + if (badge) h.appendChild(badge); + } + toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt'); + return true; + } catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; } +} + +// Trash pane: same list slot, different contents. Deliberately flat (no day +// grouping) — the interesting order here is "when did I delete it", and the +// remaining days matter more than the recording date. +async function refreshTrashList(listEl) { + let data; + try { data = await api.get('/api/trash'); } + catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } + const items = data.items || []; + if (!items.length) { + listEl.innerHTML = `
Papierkorb ist leer.
Gelöschte Aufnahmen bleiben ${data.days} Tage hier.
`; + return; + } + listEl.innerHTML = `
Wird nach ${data.days} Tagen automatisch endgültig gelöscht.
` + items.map(r => ` +
+
+ ${escapeHtml(r.title) || 'Aufnahme #' + r.id} +
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)} + · noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}
+
+ + +
`).join(''); + listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => { + await api.post(`/api/trash/${b.dataset.restore}/restore`, {}); + toast('Wiederhergestellt'); + refreshTrashList(listEl); + }); + listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => { + const rec = items.find(r => r.id == b.dataset.purge); + const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge; + if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return; + await api.del('/api/trash/' + b.dataset.purge); + toast('Endgültig gelöscht'); + refreshTrashList(listEl); + }); +} + +async function openRecording(id) { + state.selectedId = id; + const detail = document.querySelector('#detail'); + if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); } + // Highlight the selected row without rebuilding the list (keeps it stable). + document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id)); + detail.innerHTML = '
Lade…
'; + const r = await api.get('/api/recordings/' + id); + const pending = r.analysis_status === 'pending'; + const errored = r.analysis_status === 'error'; + const transcribing = r.status === 'transcribing'; + const transcribeFailed = r.status === 'error'; + const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3 + const spkOrder = Object.keys(scls); // first-appearance order + const names = r.speaker_names || {}; // rawSpeaker -> name + const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1)); + // Speaker segments + turns for the lane visualizer and the live-text box. + const segsBySpk = speakerSegments(r.words || []); + const turns = wordTurns(r.words || []); + const durMs = r.duration_ms || (r.words && r.words.length ? r.words[r.words.length - 1].t_end_ms : 0) || 1; + + // Draggable module blocks; the saved order wins, unknown/new modules append. + // NOT modules: the transcript (own sticky right column) and the speaker-lane + // visualizer (fixed full-width strip under the player, spanning both columns). + const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos', transcript: 'Transkript' }; + const vizHtml = spkOrder.length ? ` +
+
+
+ ${spkOrder.map((sp, i) => ` +
+ ${escapeHtml(spkLabel(sp, i))} +
${(segsBySpk[sp] || []).map(s => + ``).join('')} +
+
`).join('')} +
+
+
+ +
` : ''; + // Live-typed name markers: which cluster each one was resolved to, plus the + // option to add one while listening back (same resolution path) and to re-run + // the assignment after a re-diarization. + const CONF_LBL = { high: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' }; + const markersHtml = ` +

Namensmarker (während der Aufnahme getippt)

+ ${(r.markers || []).length ? `
    ${r.markers.map(m => { + const i = spkOrder.indexOf(m.speaker); + const to = m.speaker ? spkLabel(m.speaker, i >= 0 ? i : 0) : '– kein Sprecher gefunden'; + return `
  • + ${escapeHtml(m.name)} → ${escapeHtml(to)} + ${CONF_LBL[m.conf] || ''} +
  • `; + }).join('')}
` : '
Keine Marker. Tipp: während der Aufnahme Namen eintippen (Feld oben) – oder hier beim Zuhören nachtragen.
'} +
+ + + ${(r.markers || []).length ? '' : ''} +
`; + const mods = { + analyse: ` + ${r.summary ? `

${renderSummary(r.summary)}

` : '
Noch keine Zusammenfassung.
'} + ${(r.participants && r.participants.length) ? `

Teilnehmer

    ${r.participants.map(p => `
  • 👤 ${escapeHtml(p)}
  • `).join('')}
` : ''} + ${markersHtml}`, + todos: (r.todos && r.todos.length) ? `
    ${r.todos.map(t => `
  • ${escapeHtml(t)}
  • `).join('')}
` : '
Keine ToDos erkannt.
', + transcript: ` +
+ ${r.transcript_clean ? `

Dialog (KI · Sprecher aus Kontext)

${renderDialog(r.transcript_clean)}
` : ''} +
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
+
${renderTurns(r.words, names, scls, spkOrder)}
+
+ `, + }; + // Two drop columns; every module (incl. the transcript/table panel) can be + // dragged within AND between them. Layout persists for all recordings. + const defLayout = { main: ['analyse', 'todos'], side: ['transcript'] }; + let layout = null; + try { layout = JSON.parse(localStorage.getItem('aime-detail-layout') || 'null'); } catch {} + if (!layout || !Array.isArray(layout.main) || !Array.isArray(layout.side)) { + // migrate the old single-column order if present + let old = []; + try { old = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {} + layout = { main: old.filter(m => m !== 'transcript'), side: [] }; + } + layout.main = layout.main.filter(m => mods[m] !== undefined); + layout.side = layout.side.filter(m => mods[m] !== undefined && !layout.main.includes(m)); + for (const m of [...defLayout.main, ...defLayout.side]) + if (!layout.main.includes(m) && !layout.side.includes(m)) + (defLayout.side.includes(m) ? layout.side : layout.main).push(m); + + const modHtml = (m) => ` +
+
⠿

${MOD_TITLES[m]}

+
${mods[m]}
+
`; + + detail.innerHTML = ` +
+

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

+ + + ${vizHtml} +
+ ${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} + ${transcribing ? '
📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).
' : ''} + ${transcribeFailed ? `
Transkription fehlgeschlagen.
` : ''} + ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU)
' : ''} + ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} + ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''} + ${spkOrder.length ? `` : ''} +
+
+
${layout.main.map(modHtml).join('')}
+
${layout.side.map(modHtml).join('')}
+
+
`; + + // Drag & drop across BOTH columns: grab any module by its header, drop it in + // either column at any position; the layout is saved for all recordings. + const cols = [...detail.querySelectorAll('.mod-col')]; + // ":empty" never matches because of template whitespace -> class-driven. + const markEmpty = () => cols.forEach(c => c.classList.toggle('empty', !c.querySelector('.module'))); + markEmpty(); + let dragged = null; + detail.querySelectorAll('.mod-head').forEach(h => { + h.addEventListener('dragstart', (e) => { + dragged = h.closest('.module'); + dragged.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data + }); + h.addEventListener('dragend', () => { + if (!dragged) return; + dragged.classList.remove('dragging'); + dragged = null; + const save = {}; + for (const c of cols) save[c.dataset.col] = [...c.querySelectorAll('.module')].map(m => m.dataset.mod); + localStorage.setItem('aime-detail-layout', JSON.stringify(save)); + markEmpty(); + }); + }); + cols.forEach(col => col.addEventListener('dragover', (e) => { + if (!dragged) return; + e.preventDefault(); + const others = [...col.querySelectorAll('.module:not(.dragging)')]; + const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2); + if (next) col.insertBefore(dragged, next); else col.appendChild(dragged); + markEmpty(); + })); + const renBtn = detail.querySelector('#renametitle'); + if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || ''); + const reBtn = detail.querySelector('#reanalyze'); + if (reBtn) reBtn.onclick = async () => { + // Re-runs speaker separation every time: empty = automatic detection (now + // consolidated, so no more "14 speakers" for a 2-person call), a number pins + // the count. Cancel aborts. + const ans = window.prompt('Sprecher neu erkennen?\n(leer = automatisch erkennen · Zahl = feste Anzahl vorgeben)', ''); + if (ans === null) return; + const numSpeakers = parseInt(ans, 10); + const body = numSpeakers > 0 ? { numSpeakers } : {}; + reBtn.disabled = true; reBtn.textContent = 'trennt neu…'; + try { await api.post('/api/recordings/' + id + '/reanalyze', body); setTimeout(() => openRecording(id), 1500); } + catch (e) { toast(e.message, true); } + }; + const rtBtn = detail.querySelector('#retranscribe'); + if (rtBtn) rtBtn.onclick = async () => { + rtBtn.disabled = true; rtBtn.textContent = 'startet…'; + try { await api.post('/api/recordings/' + id + '/retranscribe', {}); setTimeout(() => openRecording(id), 1500); } + catch (e) { toast(e.message, true); rtBtn.disabled = false; } + }; + ensureAnaTicker(); + // Poll while transcription OR the NPU analysis is still running. The old + // version re-ran openRecording(id) blindly every 5s — a stale closure: after + // switching to ANOTHER recording the timer still fired and yanked the user + // (and the playing audio) back to the analysing one. Now: stop when the + // selection changed, poll ONLY the status while busy (no DOM rebuild), and + // rebuild once when the results are in — restoring the player position. + if (pending || transcribing) { + const pollDetail = async () => { + if (state.selectedId !== id || state.tab !== 'recordings') return; // user moved on + if (!document.querySelector('#detail')) return; // view gone + let cur; + try { cur = await api.get('/api/recordings/' + id); } catch { return; } + if (state.selectedId !== id || state.tab !== 'recordings') return; // re-check after await + const busy = cur.analysis_status === 'pending' || cur.status === 'transcribing'; + if (busy) { setTimeout(pollDetail, 5000); return; } + // Results arrived → one rebuild; keep the audio where the user had it. + const a = document.querySelector('#detail #player'); + const keep = a && !a.paused ? { t: a.currentTime, play: true } : (a ? { t: a.currentTime, play: false } : null); + await openRecording(id); + if (keep && keep.t > 0) { + const na = document.querySelector('#detail #player'); + if (na) { na.currentTime = keep.t; if (keep.play) na.play().catch(() => {}); } + } + }; + setTimeout(pollDetail, 5000); + } + const audio = detail.querySelector('#player'); + // ---- marker actions (seek / add at playhead / delete / re-assign) ---- + detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => { + audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play(); + }); + const mkAt = detail.querySelector('#mkat'), mkAtBtn = detail.querySelector('#mkatbtn'); + const addAt = async () => { + const name = (mkAt.value || '').trim(); + if (!name) return; + try { + await api.post(`/api/recordings/${id}/markers`, { name, t_ms: Math.round(audio.currentTime * 1000) }); + mkAt.value = ''; + await api.post(`/api/recordings/${id}/apply-markers`, {}); + toast(`🏷 ${name} gesetzt & zugeordnet`); + openRecording(id); + } catch (e) { toast(e.message, true); } + }; + if (mkAtBtn) mkAtBtn.onclick = addAt; + if (mkAt) mkAt.onkeydown = (e) => { if (e.key === 'Enter') addAt(); }; + detail.querySelectorAll('[data-mkdel]').forEach(b => b.onclick = async () => { + try { await api.del(`/api/recordings/${id}/markers/${b.dataset.mkdel}`); openRecording(id); } + catch (e) { toast(e.message, true); } + }); + const amk = detail.querySelector('#applymk'); + if (amk) amk.onclick = async () => { + amk.disabled = true; amk.textContent = 'ordne zu…'; + try { + const r2 = await api.post(`/api/recordings/${id}/apply-markers`, {}); + toast(`${r2.named} Sprecher benannt, ${r2.merged} zusammengeführt, ${r2.learned} Stimme(n) gelernt`); + openRecording(id); + } catch (e) { toast(e.message, true); amk.disabled = false; } + }; + const spans = [...detail.querySelectorAll('.w')]; + spans.forEach(s => s.onclick = () => { audio.currentTime = (+s.dataset.start) / 1000 + 0.001; audio.play(); }); + // Whisper (batched) word timestamps run slightly AHEAD of the audio, so a + // pure [start,end] match lit the NEXT word ("eine Zeile weiter") before it was + // spoken. Compensate with a fixed lead offset and highlight the LAST word whose + // start has passed (stays lit through inter-word gaps → no flicker). + // Tune SYNC_MS if the marker still leads (raise) or lags (lower). + const SYNC_MS = 250; + + // ---- View switch: transcript <-> conference-table scene ---- + const switchBtn = detail.querySelector('#viewswitch'); + const tView = detail.querySelector('#table-view'); + const trView = detail.querySelector('#transcript-view'); + const sideTitle = detail.querySelector('#sidetitle'); + const bubble = detail.querySelector('#tbubble'); + const tIdle = detail.querySelector('#tidle'); + const persons = [...detail.querySelectorAll('.tperson')]; + let bubbleTurnIdx = -1; + const applyViewMode = () => { + const table = localStorage.getItem('aime-side-view') === 'table'; + if (tView) tView.classList.toggle('hidden', !table); + if (trView) trView.classList.toggle('hidden', table); + if (sideTitle) sideTitle.textContent = table ? 'Am Tisch' : 'Transkript'; + if (switchBtn) switchBtn.innerHTML = table ? ICON_SHEET : ICON_TABLE; + }; + if (switchBtn) { + switchBtn.onclick = () => { + const now = localStorage.getItem('aime-side-view') === 'table' ? 'text' : 'table'; + localStorage.setItem('aime-side-view', now); + applyViewMode(); + }; + applyViewMode(); + } + // All eyes on the speaker: shift every person's pupils toward the active seat. + const lookAt = (activeSp) => { + const target = persons.find(p => p.dataset.sp === activeSp); + for (const p of persons) { + const eyes = p.querySelector('.teyes'); + if (!eyes) continue; + if (!target || p === target) { eyes.style.transform = 'translate(0, 1px)'; continue; } + const dx = (+target.dataset.x) - (+p.dataset.x); + const dy = (+target.dataset.y) - (+p.dataset.y); + const len = Math.hypot(dx, dy) || 1; + eyes.style.transform = `translate(${(dx / len * 3.2).toFixed(1)}px, ${(dy / len * 2.4).toFixed(1)}px)`; + } + }; + const updateTable = (t) => { + if (!tView || tView.classList.contains('hidden')) return; + let idx = -1; + for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; } + if (idx < 0) return; + const tu = turns[idx]; + if (idx !== bubbleTurnIdx) { + bubbleTurnIdx = idx; + const i = spkOrder.indexOf(tu.sp); + const seats = tableSeats(Math.min(persons.length, spkOrder.length)); + const seat = seats[i >= 0 ? Math.min(i, persons.length - 1) : 0]; + if (bubble && seat) { + // The bubble sits FIXED on the table; only its tail (the "Öffnung") + // wanders to point at whoever is speaking. + bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`; + bubble.style.left = ''; + bubble.style.top = ''; + // Tail x-position along the top/bottom edge = the speaker's seat mapped + // into bubble-local coordinates (bubble is centred, BUBBLE_W wide). + const ax = Math.max(8, Math.min(92, (seat.x - (50 - BUBBLE_W / 2)) / BUBBLE_W * 100)); + bubble.style.setProperty('--ax', ax.toFixed(1) + '%'); + bubble.querySelector('.tb-name').textContent = spkLabel(tu.sp, i >= 0 ? i : 0); + // The full turn as clickable word spans — highlighted + scrolled below. + bubble.querySelector('.tb-text').innerHTML = + tu.words.map(w => `${escapeHtml(w.w)}`).join(' '); + } + if (tIdle) tIdle.classList.add('hidden'); + lookAt(tu.sp); + } + persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused)); + // Word highlight + follow-scroll inside the bubble (like the transcript). + if (bubble && !bubble.classList.contains('hidden')) { + const scroller = bubble.querySelector('.tb-scroll'); + const bws = bubble.querySelectorAll('.bw'); + let act = null; + for (const s of bws) { if (+s.dataset.s <= t) act = s; else break; } + bws.forEach(s => s.classList.toggle('active', s === act)); + if (act && scroller) { + const target = act.offsetTop - scroller.clientHeight / 2 + 10; + if (Math.abs(scroller.scrollTop - target) > 24) scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' }); + } + } + }; + if (bubble) bubble.addEventListener('click', (e) => { + const s = e.target.closest('.bw'); + if (s) { audio.currentTime = (+s.dataset.s) / 1000 + 0.001; audio.play().catch(() => {}); } + }); + + // Lane visualizer wiring: playhead + speaking lane + live-text box. + const lanesEl = detail.querySelector('#lanes'); + const playheadEl = detail.querySelector('#playhead'); + const liveboxEl = detail.querySelector('#livebox'); + let liveTurnIdx = -1; + if (lanesEl) { + // Click inside a LANE seeks; clicks on the label column open the editor. + const seek = (e) => { + if (!e.target.closest('.lane')) return; + const lane = lanesEl.querySelector('.lane'); + if (!lane) return; + const rc = lane.getBoundingClientRect(); + const frac = Math.min(1, Math.max(0, (e.clientX - rc.left) / rc.width)); + audio.currentTime = frac * durMs / 1000; + audio.play().catch(() => {}); + }; + lanesEl.addEventListener('click', seek); + } + // Speaker editor popover: click a lane label -> rename + optionally learn the + // voice (replaces the old separate "Sprecher" module). + const pop = detail.querySelector('#spkpop'); + if (pop && lanesEl) { + let popSp = null; + const popName = pop.querySelector('#spkpop-name'); + lanesEl.querySelectorAll('.lane-label').forEach(l => l.onclick = (e) => { + e.stopPropagation(); + const row = l.closest('.lane-row'); + popSp = row.dataset.sp; + pop.style.top = (row.offsetTop + 30) + 'px'; + pop.classList.remove('hidden'); + const i = spkOrder.indexOf(popSp); + popName.value = names[popSp] || ''; + popName.placeholder = spkLabel(popSp, i >= 0 ? i : 0); + popName.focus(); + }); + pop.querySelector('#spkpop-cancel').onclick = () => pop.classList.add('hidden'); + pop.querySelector('#spkpop-save').onclick = async () => { + const name = popName.value.trim(); + const enroll = pop.querySelector('#spkpop-learn').checked; + const btn = pop.querySelector('#spkpop-save'); + btn.disabled = true; btn.textContent = '…'; + try { + const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: popSp, name, enroll }); + const base = res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert'); + toast(res.relabeled ? base + ' · Name in Zusammenfassung übernommen' : base); + openRecording(id); + } catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; } + }; + } + const updateViz = (t) => { + if (!lanesEl) return; + if (playheadEl) { + const lane = lanesEl.querySelector('.lane'); + if (lane) { + const left = lane.offsetLeft + Math.min(1, Math.max(0, t / durMs)) * lane.offsetWidth; + playheadEl.style.left = left + 'px'; + playheadEl.style.display = 'block'; + } + } + // Which speakers talk right now → light their lane rows. + lanesEl.querySelectorAll('.lane-row').forEach(row => { + const sp = row.dataset.sp; + const on = (segsBySpk[sp] || []).some(s => t >= s.start && t <= s.end); + row.classList.toggle('speaking', on && !audio.paused); + }); + // Live text: last turn whose start has passed (stays through gaps). + if (liveboxEl) { + let idx = -1; + for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; } + if (idx !== liveTurnIdx && idx >= 0) { + liveTurnIdx = idx; + const tu = turns[idx]; + const i = spkOrder.indexOf(tu.sp); + liveboxEl.innerHTML = ` +
+ ${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))} + ${escapeHtml(tu.text)} +
`; + } + } + }; + audio.onpause = () => { + if (lanesEl) lanesEl.querySelectorAll('.lane-row.speaking').forEach(r2 => r2.classList.remove('speaking')); + persons.forEach(p => p.classList.remove('talking')); + }; + audio.ontimeupdate = () => { + const t = audio.currentTime * 1000 - SYNC_MS; + let active = null; + for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; } + spans.forEach(s => s.classList.toggle('active', s === active)); + // Follow the active word INSIDE the transcript panel (it scrolls itself now — + // scrollIntoView would also yank the page). + if (active && (!trView || !trView.classList.contains('hidden'))) { + const panel = detail.querySelector('#transcript-panel'); + if (panel) { + const pr = panel.getBoundingClientRect(), ar = active.getBoundingClientRect(); + if (ar.top < pr.top + 40 || ar.bottom > pr.bottom - 40) + panel.scrollTo({ top: panel.scrollTop + (ar.top - pr.top) - panel.clientHeight / 2, behavior: 'smooth' }); + } + } + updateViz(t); + updateTable(t); + }; + // (Speaker renaming lives in the lane-label popover above.) + + // Spacebar toggles play/pause of the open recording (unless typing in a field). + if (state.spaceHandler) document.removeEventListener('keydown', state.spaceHandler); + state.spaceHandler = (e) => { + if (e.code !== 'Space' && e.key !== ' ') return; + const t = e.target, tag = (t.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return; + const a = document.querySelector('#player'); + if (!a) return; + e.preventDefault(); + if (a.paused) a.play(); else a.pause(); + }; + document.addEventListener('keydown', state.spaceHandler); + + // Two-pane layout: scroll the detail pane to its top. On a narrow (stacked) + // screen also bring the detail into view since it sits below the list. + const pane = detail.closest('.rec-detail-pane'); + if (pane) pane.scrollTo({ top: 0, behavior: 'smooth' }); + if (window.matchMedia('(max-width: 800px)').matches) detail.scrollIntoView({ behavior: 'smooth' }); +} + +function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + +// The section summary comes back as "Abschnittstitel:\nFließtext…" blocks (see +// llm.js analyzeLong). Flat, that reads as one wall of text, so the title lines +// get set in bold. Done at RENDER time on purpose, not stored in the database: +// it applies to every summary that already exists, without re-running a +// 20-minute analysis on 22 recordings. +function renderSummary(text) { + return escapeHtml(text || '').split('\n').map(line => { + const t = line.trim(); + // A heading is a SHORT, STANDALONE line ending in a colon. Both conditions + // matter: prose like "…die Struktur der Projektberichterstattung: kurze + // Einordnung, aktueller Stand…" has the colon mid-line and keeps running, + // and excluding . ! ? stops a full sentence that happens to end in a colon. + return /^[^.!?]{3,90}:$/.test(t) ? `${t}` : line; + }).join('\n'); +} + +// ---- Konferenztisch-Ansicht ---- +// Seat positions (person center, % of scene) per participant count, arranged +// around the rectangular table; each seat knows which bubble-arrow side points +// at it from the table-centre bubble. +const SEAT_LAYOUTS = { + 1: [{ x: 50, y: 84, side: 'bottom' }], + 2: [{ x: 50, y: 14, side: 'top' }, { x: 50, y: 84, side: 'bottom' }], + 3: [{ x: 50, y: 13, side: 'top' }, { x: 26, y: 84, side: 'bottom' }, { x: 74, y: 84, side: 'bottom' }], + 4: [{ x: 50, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }], + 5: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 60, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 60, side: 'left' }], + 6: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 68, y: 86, side: 'bottom' }, { x: 32, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }], +}; +// Bubble width in scene-% (kept in sync with .tbubble in style.css) — needed to +// map a seat's x onto the bubble edge so the tail points AT the speaker. +const BUBBLE_W = 54; +function tableSeats(n) { return SEAT_LAYOUTS[Math.min(6, Math.max(1, n))] || SEAT_LAYOUTS[6]; } + +// Toggle-button icons: three people around a table / a text sheet. +// Stecknadel als SVG statt 📌: ein Farb-Emoji bleibt immer rot, `currentColor` +// lässt sich dagegen einfärben — angeheftet = grün, sonst blass wie die +// übrigen Zeilenknöpfe. +const ICON_PIN = ``; +const ICON_TABLE = ``; +const ICON_SHEET = ``; + +// The scene: rounded table, one person per speaker (their lane colour), a +// speech bubble ON the table whose arrow + border follow whoever talks. +function buildTableScene(spkOrder, scls, names, spkLabel) { + if (!spkOrder.length) return '
Keine Sprecherdaten.
'; + const seats = tableSeats(spkOrder.length); + return ` +
+
+ ${spkOrder.slice(0, seats.length).map((sp, i) => { + const s = seats[i]; + return ` +
+
+
+
${escapeHtml(spkLabel(sp, i))}
+
`; + }).join('')} + +
▶ Abspielen — die Runde beginnt zu sprechen.
+
`; +} + +// Per-speaker talk segments for the lane visualizer: consecutive words of the +// same speaker, gaps ≤ 1.2 s bridged (word-level gaps would render as dust). +function speakerSegments(words) { + const by = {}; + for (const w of words) { + if (!w.speaker) continue; + const list = by[w.speaker] || (by[w.speaker] = []); + const last = list[list.length - 1]; + if (last && w.t_start_ms - last.end <= 1200) last.end = Math.max(last.end, w.t_end_ms); + else list.push({ start: w.t_start_ms, end: w.t_end_ms }); + } + return by; +} +// Turn list for the table scene: speaker, joined text AND the single words +// (with start times) so the bubble can highlight word-by-word like the transcript. +function wordTurns(words) { + const turns = []; + for (const w of words) { + const last = turns[turns.length - 1]; + if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; last.words.push({ w: w.word, s: w.t_start_ms }); } + else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms, words: [{ w: w.word, s: w.t_start_ms }] }); + } + return turns; +} +// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3. +function speakerClasses(words) { + const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; + for (const w of words) { if (w.speaker && !(w.speaker in m)) { m[w.speaker] = palette[i % palette.length]; i++; } } + return m; +} +// Render the raw transcript as speaker turns with a NAME header per turn. Each +// word stays its own .w span (click-to-seek + active highlight still work). +function renderTurns(words, names, scls, order) { + if (!words || !words.length) return 'Kein Transkript.'; + const idx = {}; order.forEach((s, i) => idx[s] = i); + const label = sp => (names && names[sp]) || (sp ? 'Sprecher ' + ((idx[sp] ?? 0) + 1) : ''); + let html = '', cur = '\0', buf = []; + const flush = () => { + if (!buf.length) return; + const nm = label(cur === '\0' ? null : cur); + html += `
${nm ? `${escapeHtml(nm)} ` : ''}${buf.join(' ')}
`; + buf = []; + }; + for (const w of words) { + const sp = w.speaker || null; + if (sp !== cur) { flush(); cur = sp; } + buf.push(`${escapeHtml(w.word)}`); + } + flush(); + return html; +} +// Render "Name: Text" lines as a colour-coded dialogue (speakers inferred by +// the LLM from context — not acoustic). +function renderDialog(text) { + const lines = (text || '').split(/\n+/).map(l => l.trim()).filter(Boolean); + const colors = {}; let ci = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; + return lines.map(l => { + const m = l.match(/^([^:]{1,30}):\s*(.*)$/); + if (!m) return `
${escapeHtml(l)}
`; + const spk = m[1].trim(); + if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; } + return `
${escapeHtml(spk)} ${escapeHtml(m[2])}
`; + }).join(''); +} +function analysisBadge(st) { + if (st === 'pending') return '🧠 Analyse…'; + if (st === 'error') return 'KI-Fehler'; + if (st === 'done') return 'KI ✓'; + return ''; +} + +// Der Aufnahmezustand lebt im Server (recorder.js `current`), nicht im Browser. +// Ohne diesen Abgleich zeigt der Recorder nach jedem Neuladen 00:00, obwohl +// parecord weiterläuft — und der Stop-Knopf ist unerreichbar, weil das Kopfband +// dann den Start-Knopf malt. +async function rehydrateRecording() { + try { + const h = await api.get('/api/health'); + if (!h || !h.recording) return; + state.recording = true; + state.recStart = h.startedAt || Date.now(); + state.markers = h.markers || []; + state.mkDraft = ''; + render(); + if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); } + } catch (err) { /* Server nicht erreichbar: normale Ansicht, kein Toast */ } +} + +render(); +rehydrateRecording(); diff --git a/app/web/src/style.css b/app/web/src/style.css index 86f1e86..3cf595d 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -527,12 +527,20 @@ body.resizing { cursor: col-resize; user-select: none; } .ptab.active { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); font-weight: 600; } .ptab.add { color: var(--muted); border-style: dashed; border-color: var(--line); } .ptab.add:hover { color: var(--accent); border-color: var(--accent); } -.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; } +/* pointer-events aus: sonst feuert beim Ueberfahren des Zaehlers ein + dragleave am Reiter und die Ablagemarkierung flackert. */ +.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; pointer-events: none; } +/* Reiter als Ablageziel, waehrend ein Pin darueber gezogen wird. */ +.ptab.drop { + color: var(--accent); background: var(--accent-soft); + border-color: var(--accent); border-style: dashed; +} .proj-level { margin-top: 14px; } .proj-level-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } .proj-level-head .grow { flex: 1; } .proj-level .item.rec { cursor: pointer; } +.proj-level .item.rec.dragging { opacity: .45; border-style: dashed; } /* Stecknadel in der Aufnahmenliste: blass, solange die Aufnahme nirgends hängt — angeheftet wird sie gruen (dieselbe Farbe wie „KI ✓“, also diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 3c2bb68..149877d 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,39 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-08-14 — Angeheftete Aufnahmen per Drag & Drop zwischen Unterreitern verschieben + +**Wunsch:** eine im Projekt abgelegte Aufnahme sollte sich innerhalb des Ordners umhängen lassen, z. B. von +`DFG/Projektleiter Runden` nach `DFG/INF-Projektboard`. Bisher ging das nur über Lösen + neu Anheften, +also zwei Dialoge für eine Bewegung. + +**Umsetzung:** die Pin-Zeilen in der Ordneransicht sind `draggable`, die Reiter der Reiterleiste sind +Ablageziele (`bindPinDrag` in `main.js`). Ein Drop schickt `PATCH /api/projects/:id/pins/:pinId {tab_id}`; +`db.movePin` schreibt `project_pins.tab_id` um. Verschoben wird ausschließlich die Referenz — an der +Aufnahme, ihrer Analyse oder ihren übrigen Anheftungen ändert sich nichts. + +Drei Punkte, die beim Bauen aufgefallen sind: + +- Hängt die Aufnahme auf der Zielebene bereits, würde das `UPDATE` an `UNIQUE uniq_pin` scheitern. + `movePin` löscht dann den Quell-Pin (**Zusammenführen statt Fehler**) und meldet `{merged:true}`. +- Der Zielreiter wird serverseitig gegen den Ordner geprüft (`project_tabs.project_id`), sonst könnte ein + veralteter Browser-Tab einen Pin in ein fremdes Projekt schieben → sonst 404. +- `.ptab-n` (Zähler im Reiter) braucht `pointer-events: none`, sonst feuert beim Überfahren des Zählers + ein `dragleave` und die Ablagemarkierung flackert. + +**Verifiziert** direkt gegen die laufende App (Ordner „DFG", Pin #12): Verschieben Reiter 2 → 3 → 2 jeweils +`{"moved":true}` und `tab_id` in der DB entsprechend; fremder Reiter (999) und fremdes Projekt (2) je +`404 {"error":"Anheftung oder Zielreiter nicht gefunden"}`; gleicher Reiter `{"moved":false}` ohne +Schreibzugriff; Zusammenführen getestet mit Aufnahme #109 (zwei Pins → einer). Testdaten danach auf den +Ausgangsstand zurückgesetzt. `vite build` sauber, `systemctl restart callrec` erst nach Prüfung auf +`analysis_status='pending'` (keine laufende Analyse, keine laufende Aufnahme). + +Dateien: `app/db.js` (`movePin`), `app/server.js` (`PATCH …/pins/:pinId`), `app/web/src/main.js` +(`bindPinDrag`, `draggable` an der Pin-Zeile, Hinweistext in der Ebenen-Kopfzeile), +`app/web/src/style.css` (`.ptab.drop`, `.item.rec.dragging`, `.ptab-n`). +Board-Backups: `.bak-movepin` (db.js, server.js, web/src/main.js, web/src/style.css, web/dist). +Kapitel 10 (neuer Abschnitt „Verschieben per Drag & Drop" + Sequenzdiagramm + API-Tabelle). + ## 2026-08-10 — Recorder überlebt das Neuladen (GUI holt den Zustand vom Server) **Symptom:** während einer laufenden Aufnahme klickte der Nutzer in eine andere Aufnahme; danach stand der diff --git a/docs/10-projektansicht.md b/docs/10-projektansicht.md index 6fbfeac..357829a 100644 --- a/docs/10-projektansicht.md +++ b/docs/10-projektansicht.md @@ -96,6 +96,49 @@ Ein Klick auf eine angeheftete Aufnahme springt in den Reiter „Aufnahmen" und Detailbereich (`jumpToRecording`, dieselbe Mechanik wie der Sprung aus einem Suchtreffer, nur ohne Zeitmarke). +## Verschieben per Drag & Drop (2026-08-14) + +Eine angeheftete Aufnahme lässt sich in der Ordneransicht auf einen **anderen Reiter der Reiterleiste +ziehen** und wechselt damit die Ebene — z. B. von „Projektleiter Runden" nach „INF-Projektboard". +Verschoben wird nur der Pin (`project_pins.tab_id`); die Aufnahme selbst wird nicht angefasst. + +```mermaid +sequenceDiagram + participant U as Nutzer + participant G as GUI (main.js) + participant S as server.js + participant D as MariaDB + U->>G: dragstart auf 📌-Zeile + Note over G: merkt pin_id,
setzt dataTransfer (Firefox braucht das) + U->>G: dragover Zielreiter + G-->>U: Reiter gestrichelt markiert (.ptab.drop)
eigener Reiter = kein Ziel + U->>G: drop + G->>S: PATCH /api/projects/:id/pins/:pinId {tab_id} + S->>D: db.movePin() + alt Zielreiter gehört nicht zum Ordner / Pin unbekannt + D-->>S: null + S-->>G: 404 + else Aufnahme hängt dort schon + D->>D: DELETE Quell-Pin (Zusammenführen) + S-->>G: {moved:true, merged:true} + else Normalfall + D->>D: UPDATE project_pins SET tab_id + S-->>G: {moved:true} + end + G->>G: renderProjectDetail() neu zeichnen +``` + +Drei Details, die nicht offensichtlich sind: + +- **Zusammenführen statt Duplikat:** hängt dieselbe Aufnahme auf der Zielebene bereits, würde das `UPDATE` + an `UNIQUE uniq_pin` scheitern. `movePin` löscht in diesem Fall den Quell-Pin, statt einen Fehler zu + werfen — im Ergebnis ist die Aufnahme genau einmal auf der Zielebene, was der Nutzer erwartet hat. +- **Ziel wird serverseitig geprüft:** `tab_id` muss ein Reiter *desselben* Ordners sein (oder 0 für die + Ordner-Ebene), sonst 404. Ein veralteter Reiter im offenen Browser-Tab kann so keinen Pin in ein + fremdes Projekt schieben. +- **`.ptab-n` (der Zähler im Reiter) bekommt `pointer-events: none`.** Sonst feuert beim Überfahren des + Zählers ein `dragleave` am Reiter und die Ablagemarkierung flackert. + ## API | Route | Zweck | @@ -109,13 +152,15 @@ Zeitmarke). | `PATCH /api/projects/:id/tabs/:tid` | Unterreiter umbenennen; `:tid = 0` benennt die Ordner-Ebene um (leerer Name = zurück auf „Ordner") | | `DELETE /api/projects/:id/tabs/:tid` | Unterreiter löschen | | `POST /api/projects/:id/pins` | anheften `{recording_id, tab_id}` (`tab_id` fehlt/0 = Ordner-Ebene) | +| `PATCH /api/projects/:id/pins/:pinId` | Anheftung auf eine andere Ebene desselben Ordners verschieben `{tab_id}` (Drag & Drop); 404 bei fremdem Reiter/Ordner, `{merged:true}` wenn dort schon vorhanden | | `DELETE /api/projects/:id/pins/:pinId` | Anheftung lösen | | `GET /api/recordings/:id/pins` | wo hängt diese Aufnahme überall | | `GET /api/pins/counts` | `{recording_id: anzahl}` für die ganze Liste | ## Dateien -`app/db.js` (Tabellen + CRUD), `app/server.js` (Routen), `app/web/src/main.js` -(`viewProjects`/`renderProjectList`/`renderProjectDetail`/`pinPickRecordingDialog`/`pinToProjectDialog`/ -`openModal`), `app/web/src/style.css` (Ordnergitter, Reiterleiste, Dialog). -Board-Backups: `.bak-projects`. +`app/db.js` (Tabellen + CRUD, `movePin`), `app/server.js` (Routen), `app/web/src/main.js` +(`viewProjects`/`renderProjectList`/`renderProjectDetail`/`bindPinDrag`/`pinPickRecordingDialog`/ +`pinToProjectDialog`/`openModal`), `app/web/src/style.css` (Ordnergitter, Reiterleiste, `.ptab.drop`, +`.item.rec.dragging`, Dialog). +Board-Backups: `.bak-projects` (Grundfunktion), `.bak-movepin` (Drag & Drop).