Files
tueit_Transkriptor/frontend/app.js
T
thomas.kopp 0eb85b98f1 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 <noreply@anthropic.com>
2026-04-02 01:17:23 +02:00

285 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const btn = document.getElementById('record-btn');
const statusText = document.getElementById('status-text');
const headerStatus = document.getElementById('header-status');
const instructionsEl = document.getElementById('instructions');
const transcriptList = document.getElementById('transcript-list');
const userChip = document.getElementById('user-chip');
const logoutBtn = document.getElementById('logout-btn');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const modalBody = document.getElementById('modal-body');
const modalOpenBtn = document.getElementById('modal-open-btn');
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',
};
// Auth token is stored in sessionStorage so it's gone when the tab closes.
// On first load, if no token is present the server will redirect to /login.
const token = sessionStorage.getItem('token');
function authHeaders() {
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
function apiFetch(url, options = {}) {
return fetch(url, {
...options,
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(options.headers || {}) },
});
}
logoutBtn.addEventListener('click', () => {
apiFetch('/logout', { method: 'POST' }).finally(() => {
sessionStorage.removeItem('token');
location.href = '/login';
});
});
function openModal(filename, path) {
_modalPath = path;
_modalFilename = filename;
modalTitle.textContent = filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-\d{4}-/, '');
modalBody.innerHTML = '';
modal.classList.remove('hidden');
apiFetch(`/transcripts/${encodeURIComponent(filename)}`)
.then(r => r.text())
.then(md => {
modalBody.innerHTML = DOMPurify.sanitize(marked.parse(md));
});
}
function closeModal() {
modal.classList.add('hidden');
_modalPath = null;
_modalFilename = null;
}
modalCloseBtn.addEventListener('click', closeModal);
modal.querySelector('.modal-backdrop').addEventListener('click', closeModal);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
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',
body: JSON.stringify({ instructions: instructionsEl.value }),
});
});
function setStatus(status) {
btn.className = status;
headerStatus.className = `status-badge ${status}`;
const label = STATUS_LABELS[status] || status;
statusText.textContent = status === 'error' ? label + ' — klicken zum Zurücksetzen' : label;
headerStatus.textContent = label;
btn.disabled = status === 'processing';
}
btn.addEventListener('click', async () => {
const r = await apiFetch('/toggle', { method: 'POST' });
const data = await r.json();
if (data.action === 'started') {
setStatus('recording');
} else if (data.action === 'reset') {
setStatus('idle');
}
});
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token || '')}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.event === 'processing') setStatus('processing');
if (msg.event === 'saved') {
setStatus('idle');
loadTranscripts();
}
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;
const items = await r.json();
transcriptList.replaceChildren(
...items.map((t) => {
const div = document.createElement('div');
div.className = 'transcript-item';
const dateMatch = t.filename.match(/^(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})-/);
const dateEl = document.createElement('span');
dateEl.className = 'meta item-date';
dateEl.textContent = dateMatch ? `${dateMatch[1]} ${dateMatch[2]}:${dateMatch[3]}` : '';
const name = document.createElement('span');
name.className = 'name';
name.textContent = t.filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-\d{4}-/, '');
const meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = `${Math.round(t.size / 1024 * 10) / 10} KB`;
div.addEventListener('click', () => openModal(t.filename, t.path));
const reprocessBtn = document.createElement('button');
reprocessBtn.className = 'del-btn';
reprocessBtn.title = 'Neu verarbeiten';
reprocessBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 8 8h-2a6 6 0 1 1-1.76-4.24l-2.24 2.24H20V4l-2.35 2.35z"/></svg>';
reprocessBtn.addEventListener('click', async (e) => {
e.stopPropagation();
reprocessBtn.disabled = true;
await apiFetch(`/transcripts/${encodeURIComponent(t.filename)}/reprocess`, {
method: 'POST',
body: JSON.stringify({ instructions: instructionsEl.value }),
});
reprocessBtn.disabled = false;
loadTranscripts();
});
const delBtn = document.createElement('button');
delBtn.className = 'del-btn';
delBtn.title = 'Löschen';
delBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M9 3h6l1 1h4v2H4V4h4l1-1zm-3 5h12l-1 13H7L6 8zm5 2v9h2v-9h-2zm-3 0v9h2v-9H8zm8 0v9h2v-9h-2z"/></svg>';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await apiFetch(`/transcripts/${encodeURIComponent(t.filename)}`, { method: 'DELETE' });
loadTranscripts();
});
const actions = document.createElement('div');
actions.className = 'item-actions';
actions.append(reprocessBtn, delBtn);
div.append(dateEl, name, meta, actions);
return div;
})
);
}
(async () => {
const r = await apiFetch('/status');
if (r.status === 401) {
location.href = '/login';
return;
}
const data = await r.json();
setStatus(data.status);
if (data.username) {
userChip.textContent = data.username;
}
if (data.is_admin) {
const gearLink = document.createElement('a');
gearLink.href = '/settings';
gearLink.className = 'back-btn';
gearLink.title = 'Einstellungen';
gearLink.textContent = '\u2699';
document.querySelector('.header-right').prepend(gearLink);
}
connectWs();
loadTranscripts();
})();