From 0eb85b98f1f4ee02e73b10a9f6464b87fe01ce4d Mon Sep 17 00:00:00 2001 From: "thomas.kopp" Date: Thu, 2 Apr 2026 01:17:23 +0200 Subject: [PATCH] feat: add frontend speaker naming card for diarization Shows a card with excerpt navigation and name inputs when the backend emits speakers_unknown. Submitting posts the mapping to /speakers or leaves speakers anonymous; handles awaiting_speakers status label. Co-Authored-By: Claude Sonnet 4.6 --- frontend/app.js | 90 +++++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 45 +++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/frontend/app.js b/frontend/app.js index 841b719..7dfe8f1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -13,10 +13,19 @@ const modalCloseBtn = document.getElementById('modal-close-btn'); let _modalPath = null; let _modalFilename = null; +const speakerCard = document.getElementById('speaker-card'); +const speakerRows = document.getElementById('speaker-rows'); +const speakerConfirmBtn = document.getElementById('speaker-confirm-btn'); +const speakerAnonymBtn = document.getElementById('speaker-anonym-btn'); + +// state for excerpt navigation: { speakerId: { excerpts: [], idx: 0 } } +let _speakerState = {}; + const STATUS_LABELS = { idle: 'Bereit', recording: 'Aufnahme läuft\u2026', processing: 'Wird verarbeitet\u2026', + awaiting_speakers: 'Sprecher benennen\u2026', error: 'Fehler', }; @@ -68,6 +77,9 @@ modalOpenBtn.addEventListener('click', () => { if (_modalPath) apiFetch('/open', { method: 'POST', body: JSON.stringify({ path: _modalPath }) }); }); +speakerConfirmBtn.addEventListener('click', () => submitSpeakers(true)); +speakerAnonymBtn.addEventListener('click', () => submitSpeakers(false)); + instructionsEl.addEventListener('input', async () => { await apiFetch('/instructions', { method: 'POST', @@ -107,10 +119,88 @@ function connectWs() { if (msg.event === 'error') { setStatus('error'); } + if (msg.event === 'speakers_unknown') { + setStatus('awaiting_speakers'); + showSpeakerCard(msg.speakers); + } }; ws.onclose = () => setTimeout(connectWs, 2000); } +function showSpeakerCard(speakers) { + _speakerState = {}; + speakerRows.innerHTML = ''; + speakers.forEach(({ id, excerpts }) => { + _speakerState[id] = { excerpts, idx: 0 }; + + const row = document.createElement('div'); + row.className = 'speaker-row'; + + const nav = document.createElement('div'); + nav.className = 'excerpt-nav'; + + const prevBtn = document.createElement('button'); + prevBtn.className = 'excerpt-nav-btn'; + prevBtn.textContent = '‹'; + prevBtn.title = 'Vorheriger Ausschnitt'; + + const nextBtn = document.createElement('button'); + nextBtn.className = 'excerpt-nav-btn'; + nextBtn.textContent = '›'; + nextBtn.title = 'Nächster Ausschnitt'; + + const counter = document.createElement('span'); + counter.className = 'excerpt-counter'; + + const excerptEl = document.createElement('div'); + excerptEl.className = 'speaker-excerpt'; + + function updateExcerpt() { + const st = _speakerState[id]; + excerptEl.textContent = `"${st.excerpts[st.idx]}"`; + counter.textContent = `${st.idx + 1} / ${st.excerpts.length}`; + prevBtn.disabled = st.idx === 0; + nextBtn.disabled = st.idx === st.excerpts.length - 1; + } + + prevBtn.addEventListener('click', () => { _speakerState[id].idx--; updateExcerpt(); }); + nextBtn.addEventListener('click', () => { _speakerState[id].idx++; updateExcerpt(); }); + + nav.append(prevBtn, counter, nextBtn); + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'speaker-name-input'; + input.placeholder = `Name für ${id.replace('SPEAKER_', 'Sprecher ')}`; + input.dataset.speakerId = id; + + row.append(nav, excerptEl, input); + speakerRows.appendChild(row); + updateExcerpt(); + }); + speakerCard.classList.remove('hidden'); +} + +function hideSpeakerCard() { + speakerCard.classList.add('hidden'); + speakerRows.innerHTML = ''; + _speakerState = {}; +} + +async function submitSpeakers(useNames) { + const mapping = {}; + if (useNames) { + speakerRows.querySelectorAll('.speaker-name-input').forEach(inp => { + mapping[inp.dataset.speakerId] = inp.value.trim(); + }); + } else { + Object.keys(_speakerState).forEach(id => { mapping[id] = ''; }); + } + hideSpeakerCard(); + setStatus('processing'); + await apiFetch('/speakers', { method: 'POST', body: JSON.stringify(mapping) }); +} + async function loadTranscripts() { const r = await apiFetch('/transcripts'); if (!r.ok) return; diff --git a/frontend/index.html b/frontend/index.html index 388f9d5..9a64ec5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -169,6 +169,42 @@ transition: color 0.15s; flex-shrink: 0; } .del-btn:hover { color: var(--red); } + .speaker-card { + background: var(--surface); border: 1px solid var(--yellow); + border-radius: 10px; padding: 20px; display: flex; flex-direction: column; gap: 16px; + } + .speaker-card.hidden { display: none; } + .speaker-card-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--yellow); } + .speaker-rows { display: flex; flex-direction: column; gap: 14px; } + .speaker-row { display: flex; flex-direction: column; gap: 6px; } + .excerpt-nav { display: flex; align-items: center; gap: 8px; } + .excerpt-nav-btn { + background: none; border: 1px solid var(--border); color: var(--muted); + border-radius: 4px; padding: 2px 8px; cursor: pointer; font-family: inherit; + font-size: 0.85rem; transition: border-color 0.15s, color 0.15s; + } + .excerpt-nav-btn:hover { border-color: var(--yellow); color: var(--yellow); } + .excerpt-counter { font-size: 0.75rem; color: var(--muted); white-space: nowrap; } + .speaker-excerpt { + font-size: 0.82rem; color: var(--muted); font-style: italic; + background: var(--surface2); border-radius: 6px; padding: 8px 12px; + line-height: 1.5; min-height: 3em; + } + .speaker-name-input { + background: var(--surface2); border: 1px solid var(--border); color: var(--text); + border-radius: 6px; padding: 8px 12px; font-family: inherit; font-size: 0.9rem; + outline: none; transition: border-color 0.15s; width: 100%; + } + .speaker-name-input:focus { border-color: var(--yellow); } + .speaker-card-actions { display: flex; gap: 10px; justify-content: flex-end; } + .card-btn { + padding: 8px 18px; border-radius: 6px; border: 1px solid var(--border); + background: none; color: var(--text); cursor: pointer; font-family: inherit; + font-size: 0.85rem; transition: all 0.15s; + } + .card-btn:hover { border-color: var(--yellow); color: var(--yellow); } + .card-btn.primary { background: var(--yellow); color: #111; border-color: var(--yellow); font-weight: 600; } + .card-btn.primary:hover { background: #e6c200; border-color: #e6c200; } @@ -183,6 +219,15 @@
+ +