Files
2026-04-02 12:10:50 +02:00

332 lines
12 KiB
JavaScript
Raw Permalink 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 modalObsidianBtn = document.getElementById('modal-obsidian-btn');
const modalFolderBtn = document.getElementById('modal-folder-btn');
const modalOpenBtn = document.getElementById('modal-open-btn');
const modalCloseBtn = document.getElementById('modal-close-btn');
const modalTabs = document.getElementById('modal-tabs');
let _modalPath = null;
let _modalPaths = null;
let _modalFilename = null;
let _modalRelated = 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 _loadModalContent(filename, activeTab) {
modalBody.innerHTML = '';
apiFetch(`/transcripts/${filename.split('/').map(encodeURIComponent).join('/')}`)
.then(r => r.text())
.then(md => { modalBody.innerHTML = DOMPurify.sanitize(marked.parse(md)); });
// update active tab
modalTabs.querySelectorAll('.modal-tab').forEach(t => {
t.classList.toggle('active', t.dataset.file === filename);
});
}
function openModal(filename, path, paths, related) {
_modalPath = path;
_modalPaths = paths || null;
_modalFilename = filename;
_modalRelated = related || null;
modalTitle.textContent = filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-\d{4}-/, '').replace(/-index$/, '');
modal.classList.remove('hidden');
// Build tabs if there are related files
modalTabs.innerHTML = '';
if (related && (related.transkript || related.zusammenfassung)) {
modalTabs.style.display = 'flex';
const tabDefs = [
{ label: 'Index', file: filename },
{ label: 'Transkript', file: related.transkript },
{ label: 'Zusammenfassung', file: related.zusammenfassung },
].filter(t => t.file);
tabDefs.forEach(({ label, file }) => {
const btn = document.createElement('button');
btn.className = 'modal-tab';
btn.textContent = label;
btn.dataset.file = file;
btn.addEventListener('click', () => _loadModalContent(file, file));
modalTabs.appendChild(btn);
});
} else {
modalTabs.style.display = 'none';
}
_loadModalContent(filename, filename);
}
function closeModal() {
modal.classList.add('hidden');
_modalPath = null;
_modalPaths = null;
_modalFilename = null;
_modalRelated = null;
}
modalCloseBtn.addEventListener('click', closeModal);
modal.querySelector('.modal-backdrop').addEventListener('click', closeModal);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
modalObsidianBtn.addEventListener('click', () => {
if (_modalPaths) {
apiFetch('/open', { method: 'POST', body: JSON.stringify({ paths: Object.values(_modalPaths), mode: 'obsidian' }) });
} else if (_modalPath) {
apiFetch('/open', { method: 'POST', body: JSON.stringify({ path: _modalPath, mode: 'obsidian' }) });
}
});
modalFolderBtn.addEventListener('click', () => {
if (_modalPath) apiFetch('/open', { method: 'POST', body: JSON.stringify({ path: _modalPath, mode: 'folder' }) });
});
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, null, t.related || null));
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();
})();