// ─── DOM-Refs ────────────────────────────────────────────────────────────── const form = document.getElementById("upload-form"); const titleInput = document.getElementById("title"); const audioInput = document.getElementById("audio"); const submitBtn = document.getElementById("submit-btn"); const uploadProgress = document.getElementById("upload-progress"); const uploadBar = uploadProgress.querySelector(".bar-fill"); const uploadMsg = document.getElementById("upload-msg"); const jobsBody = document.getElementById("jobs-body"); const macStatus = document.getElementById("mac-status"); const appMain = document.getElementById("app"); const authOverlay = document.getElementById("auth-overlay"); const authTitle = document.getElementById("auth-title"); const authInfo = document.getElementById("auth-info"); const authForm = document.getElementById("auth-form"); const authUsername = document.getElementById("auth-username"); const authPassword = document.getElementById("auth-password"); const authSubmit = document.getElementById("auth-submit"); const authMsg = document.getElementById("auth-msg"); const userBar = document.getElementById("user-bar"); const userName = document.getElementById("user-name"); const userAdminBadge = document.getElementById("user-admin-badge"); const logoutBtn = document.getElementById("logout-btn"); const adminCard = document.getElementById("admin-card"); const userForm = document.getElementById("user-form"); const newUsername = document.getElementById("new-username"); const newPassword = document.getElementById("new-password"); const newIsAdmin = document.getElementById("new-is-admin"); const userMsg = document.getElementById("user-msg"); const usersBody = document.getElementById("users-body"); const profileLabel = document.getElementById("profile-label"); const profileSelect = document.getElementById("profile-select"); const defaultProfileName = document.getElementById("default-profile-name"); const headerProfileWrap = document.getElementById("header-profile-wrap"); const headerProfile = document.getElementById("header-profile"); const recordToggle = document.getElementById("record-toggle"); const recordToggleLabel = document.getElementById("record-toggle-label"); const recordPane = document.getElementById("record-pane"); const recordTimer = document.getElementById("record-timer"); const recordStatus = document.getElementById("record-status"); const recPauseBtn = document.getElementById("rec-pause"); const recStopBtn = document.getElementById("rec-stop"); const recordResult = document.getElementById("record-result"); const recordFilename = document.getElementById("record-filename"); const recordDiscardBtn = document.getElementById("record-discard"); let currentUser = null; // { id, username, is_admin, default_profile } let mode = "login"; // "login" | "setup" let availableProfiles = []; // [{ name, display_name, language }, ...] const DOWNLOADS = [ { kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" }, { kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" }, { kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" }, { kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" }, ]; // ─── Helpers ─────────────────────────────────────────────────────────────── function escapeHtml(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c])); } function fmtDate(s) { const d = new Date(s); return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" }); } async function api(path, opts = {}) { const r = await fetch(path, { credentials: "same-origin", headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); return r; } // ─── Auth-Flow ───────────────────────────────────────────────────────────── async function bootstrap() { const me = await api("/api/auth/me"); if (me.ok) { currentUser = await me.json(); enterApp(); return; } // Nicht eingeloggt — Setup nötig? const setupCheck = await api("/api/auth/needs-setup"); const setupData = await setupCheck.json(); if (setupData.needs_setup) { showAuth("setup"); } else { showAuth("login"); } } function showAuth(which) { mode = which; authMsg.textContent = ""; authMsg.className = "msg"; authForm.reset(); if (which === "setup") { authTitle.textContent = "Initialen Admin anlegen"; authInfo.textContent = "Noch kein Benutzer vorhanden. Lege jetzt den ersten Admin an. Bestehende Jobs werden diesem Konto zugeordnet."; authInfo.classList.remove("hidden"); authPassword.autocomplete = "new-password"; authPassword.minLength = 8; authSubmit.textContent = "Admin anlegen"; } else { authTitle.textContent = "Anmelden"; authInfo.classList.add("hidden"); authInfo.textContent = ""; authPassword.autocomplete = "current-password"; authPassword.minLength = 1; authSubmit.textContent = "Anmelden"; } authOverlay.classList.remove("hidden"); appMain.classList.add("hidden"); userBar.classList.add("hidden"); setTimeout(() => authUsername.focus(), 50); } function enterApp() { authOverlay.classList.add("hidden"); appMain.classList.remove("hidden"); userBar.classList.remove("hidden"); userName.textContent = currentUser.username; userAdminBadge.classList.toggle("hidden", !currentUser.is_admin); adminCard.classList.toggle("hidden", !currentUser.is_admin); loadProfiles().then(applyProfileUI); loadJobs(); if (currentUser.is_admin) loadUsers(); } async function loadProfiles() { try { const r = await api("/api/profiles"); if (!r.ok) return; const data = await r.json(); availableProfiles = data.profiles || []; } catch (e) { console.error("Profile laden fehlgeschlagen", e); availableProfiles = []; } } function profileLabelOf(name) { const p = availableProfiles.find((x) => x.name === name); return p ? p.display_name : name; } function applyProfileUI() { // Upload-Dropdown und Header-Selector nur sichtbar wenn ≥2 Profile. const showDropdown = availableProfiles.length > 1; profileLabel.classList.toggle("hidden", !showDropdown); headerProfileWrap.classList.toggle("hidden", !showDropdown); for (const sel of [profileSelect, headerProfile]) { sel.innerHTML = ""; availableProfiles.forEach((p) => { const o = document.createElement("option"); o.value = p.name; o.textContent = p.display_name; sel.appendChild(o); }); } const def = currentUser.default_profile || "meeting"; profileSelect.value = def; headerProfile.value = def; defaultProfileName.textContent = profileLabelOf(def); } // Sofort-Speichern, sobald der User im Header das Standard-Profil ändert. headerProfile.addEventListener("change", async () => { const newDefault = headerProfile.value; headerProfile.disabled = true; try { const r = await api("/api/auth/me", { method: "PATCH", body: JSON.stringify({ default_profile: newDefault }), }); if (!r.ok) throw new Error("save failed"); currentUser = await r.json(); // Upload-Dropdown synchron mitziehen profileSelect.value = currentUser.default_profile; defaultProfileName.textContent = profileLabelOf(currentUser.default_profile); } catch (e) { alert("Konnte Standard-Profil nicht speichern."); headerProfile.value = currentUser.default_profile || "meeting"; } finally { headerProfile.disabled = false; } }); authForm.addEventListener("submit", async (ev) => { ev.preventDefault(); authSubmit.disabled = true; authMsg.textContent = ""; authMsg.className = "msg"; const body = JSON.stringify({ username: authUsername.value.trim(), password: authPassword.value, }); const url = mode === "setup" ? "/api/auth/setup" : "/api/auth/login"; try { const r = await api(url, { method: "POST", body }); const data = await r.json(); if (!r.ok) { authMsg.textContent = data.detail || "Fehler bei der Anmeldung"; authMsg.className = "msg err"; return; } currentUser = data; if (mode === "setup" && typeof data.migrated_jobs === "number" && data.migrated_jobs > 0) { console.info(`${data.migrated_jobs} Bestand-Jobs wurden zugeordnet.`); } enterApp(); } catch (e) { authMsg.textContent = "Netzwerkfehler"; authMsg.className = "msg err"; } finally { authSubmit.disabled = false; } }); logoutBtn.addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST" }); currentUser = null; jobsBody.innerHTML = ""; showAuth("login"); }); // ─── Mac-Status ──────────────────────────────────────────────────────────── async function checkMac() { try { const r = await fetch("/api/mac/health"); const j = await r.json(); if (r.ok && j.reachable) { macStatus.textContent = "Mac-Backend: online"; macStatus.classList.add("ok"); macStatus.classList.remove("err"); } else { macStatus.textContent = "Mac-Backend: nicht erreichbar"; macStatus.classList.add("err"); macStatus.classList.remove("ok"); } } catch { macStatus.textContent = "Mac-Backend: Fehler"; macStatus.classList.add("err"); } } // ─── Jobs ────────────────────────────────────────────────────────────────── function row(job) { const tr = document.createElement("tr"); const title = job.title || job.original_name; const dlCell = document.createElement("td"); dlCell.className = "dl"; DOWNLOADS.forEach(({ kind, label, title: tip }) => { const a = document.createElement("a"); a.className = "btn-dl"; a.textContent = label; a.title = tip; a.href = `/api/jobs/${job.id}/download/${kind}`; if (!job.has[kind]) { a.classList.add("disabled"); a.removeAttribute("href"); a.title = `${tip} — noch nicht verfügbar`; } dlCell.appendChild(a); }); const ownerLine = currentUser?.is_admin && job.owner_username ? `
von ${escapeHtml(job.owner_username)}` : ""; // Profile-Tag nur sichtbar wenn ≥2 Profile in der Auswahl ODER es ein anderes als "meeting" ist. const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting"); const profileTag = showProfileTag ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}` : ""; const statusCell = ` ${escapeHtml(job.status)} ${job.error ? `
${escapeHtml(job.error)}
` : ""} `; tr.innerHTML = ` #${job.id} ${escapeHtml(title)} ${profileTag}
${escapeHtml(job.original_name)}${ownerLine} ${statusCell}
${job.progress}% ${fmtDate(job.updated_at)} `; tr.appendChild(dlCell); return tr; } async function loadJobs() { if (!currentUser) return; try { const r = await api("/api/jobs"); if (r.status === 401) { showAuth("login"); return; } const jobs = await r.json(); jobsBody.innerHTML = ""; if (!jobs.length) { jobsBody.innerHTML = 'Noch keine Jobs.'; return; } jobs.forEach((j) => jobsBody.appendChild(row(j))); } catch (e) { console.error(e); } } form.addEventListener("submit", (ev) => { ev.preventDefault(); // Quelle: Aufnahme bevorzugt, sonst File-Input const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null); if (!file) { uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen."; uploadMsg.className = "msg err"; return; } const fd = new FormData(); fd.append("audio", file, file.name); fd.append("title", titleInput.value); // Profile mitgeben — leer = Server nimmt user.default_profile fd.append("profile", profileSelect.value || ""); submitBtn.disabled = true; uploadProgress.classList.remove("hidden"); uploadBar.style.width = "0%"; uploadMsg.textContent = ""; uploadMsg.className = "msg"; const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/jobs"); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); uploadBar.style.width = p + "%"; } }; xhr.onload = () => { submitBtn.disabled = false; uploadProgress.classList.add("hidden"); if (xhr.status === 401) { showAuth("login"); return; } if (xhr.status >= 200 && xhr.status < 300) { const j = JSON.parse(xhr.responseText); uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`; uploadMsg.className = "msg ok"; form.reset(); discardRecording(); loadJobs(); } else { let err = "Upload fehlgeschlagen"; try { err = JSON.parse(xhr.responseText).detail || err; } catch {} uploadMsg.textContent = err; uploadMsg.className = "msg err"; } }; xhr.onerror = () => { submitBtn.disabled = false; uploadProgress.classList.add("hidden"); uploadMsg.textContent = "Netzwerkfehler beim Upload"; uploadMsg.className = "msg err"; }; xhr.send(fd); }); // ─── Admin: User-Verwaltung ──────────────────────────────────────────────── async function loadUsers() { if (!currentUser?.is_admin) return; try { const r = await api("/api/admin/users"); if (!r.ok) return; const users = await r.json(); usersBody.innerHTML = ""; users.forEach((u) => { const tr = document.createElement("tr"); const isMe = u.id === currentUser.id; tr.innerHTML = ` #${u.id} ${escapeHtml(u.username)}${isMe ? ' (du)' : ""} ${u.is_admin ? 'Admin' : 'User'} ${fmtDate(u.created_at)} ${isMe ? "" : ``} `; usersBody.appendChild(tr); }); } catch (e) { console.error(e); } } userForm.addEventListener("submit", async (ev) => { ev.preventDefault(); userMsg.textContent = ""; userMsg.className = "msg"; const body = JSON.stringify({ username: newUsername.value.trim(), password: newPassword.value, is_admin: newIsAdmin.checked, }); const r = await api("/api/admin/users", { method: "POST", body }); const data = await r.json(); if (!r.ok) { userMsg.textContent = data.detail || "Fehler beim Anlegen"; userMsg.className = "msg err"; return; } userMsg.textContent = `Benutzer '${data.username}' angelegt.`; userMsg.className = "msg ok"; userForm.reset(); loadUsers(); }); usersBody.addEventListener("click", async (ev) => { const t = ev.target; if (t.matches(".del-user")) { if (!confirm(`Benutzer '${t.dataset.name}' wirklich löschen?\nDie Jobs des Benutzers bleiben erhalten (gehören dann keinem mehr).`)) return; const r = await api(`/api/admin/users/${t.dataset.id}`, { method: "DELETE" }); if (!r.ok) { const d = await r.json().catch(() => ({})); alert(d.detail || "Löschen fehlgeschlagen"); } loadUsers(); } else if (t.matches(".reset-pw")) { const pw = prompt(`Neues Passwort für '${t.dataset.name}' (min. 8 Zeichen):`); if (!pw) return; if (pw.length < 8) { alert("Passwort muss min. 8 Zeichen haben"); return; } const r = await api(`/api/admin/users/${t.dataset.id}/password`, { method: "POST", body: JSON.stringify({ password: pw }), }); if (!r.ok) { const d = await r.json().catch(() => ({})); alert(d.detail || "Zurücksetzen fehlgeschlagen"); } else { alert("Passwort gesetzt."); } } }); // ─── Audio-Aufnahme (MediaRecorder) ──────────────────────────────────────── let mediaRecorder = null; let recordedChunks = []; let recordedFile = null; // File-Objekt nach Stop let recStream = null; // MediaStream zum sauberen Schließen let recStartedAt = 0; let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume let timerInterval = null; let wakeLock = null; function pickMimeType() { const candidates = [ "audio/webm;codecs=opus", "audio/webm", "audio/mp4", // Safari iOS "audio/mpeg", "", // Browser-Default ]; for (const m of candidates) { if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m; } return ""; } function extFromMime(mime) { if (!mime) return "webm"; if (mime.includes("webm")) return "webm"; if (mime.includes("mp4")) return "m4a"; if (mime.includes("mpeg")) return "mp3"; if (mime.includes("ogg")) return "ogg"; return "webm"; } function fmtTimer(s) { const mm = String(Math.floor(s / 60)).padStart(2, "0"); const ss = String(Math.floor(s % 60)).padStart(2, "0"); return `${mm}:${ss}`; } function tickTimer() { if (!mediaRecorder) return; let elapsed = recElapsedBeforePause; if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000; recordTimer.textContent = fmtTimer(elapsed); } async function acquireWakeLock() { try { if ("wakeLock" in navigator) { wakeLock = await navigator.wakeLock.request("screen"); } } catch { /* nicht kritisch */ } } async function releaseWakeLock() { try { if (wakeLock) await wakeLock.release(); } catch {} wakeLock = null; } async function startRecording() { if (!navigator.mediaDevices?.getUserMedia) { alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS."); return; } try { recStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); } catch (e) { alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message); return; } const mime = pickMimeType(); recordedChunks = []; try { mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream); } catch (e) { alert("MediaRecorder konnte nicht starten: " + e.message); cleanupStream(); return; } mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); }; mediaRecorder.onstop = () => { const type = mediaRecorder.mimeType || mime || "audio/webm"; const ext = extFromMime(type); const blob = new Blob(recordedChunks, { type }); const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type }); onRecordingFinalized(); }; mediaRecorder.start(1000); // alle 1s ein dataavailable-Event recStartedAt = Date.now(); recPausedAt = 0; recElapsedBeforePause = 0; recordTimer.textContent = "00:00"; recordStatus.textContent = "Aufnahme läuft"; recPauseBtn.textContent = "Pause"; recordPane.classList.remove("hidden"); recordResult.classList.add("hidden"); recordToggle.classList.add("hidden"); audioInput.value = ""; // konkurrierende Datei-Auswahl löschen timerInterval = setInterval(tickTimer, 250); acquireWakeLock(); } function pauseRecording() { if (!mediaRecorder) return; if (mediaRecorder.state === "recording") { mediaRecorder.pause(); recElapsedBeforePause += (Date.now() - recStartedAt) / 1000; recPausedAt = Date.now(); recordStatus.textContent = "Pausiert"; recPauseBtn.textContent = "Fortsetzen"; } else if (mediaRecorder.state === "paused") { mediaRecorder.resume(); recStartedAt = Date.now(); recordStatus.textContent = "Aufnahme läuft"; recPauseBtn.textContent = "Pause"; } } function stopRecording() { if (!mediaRecorder) return; if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } cleanupStream(); releaseWakeLock(); } function cleanupStream() { if (recStream) { recStream.getTracks().forEach((t) => t.stop()); recStream = null; } } function onRecordingFinalized() { recordPane.classList.add("hidden"); recordResult.classList.remove("hidden"); recordToggle.classList.remove("hidden"); recordToggleLabel.textContent = "Neue Aufnahme starten"; const sizeKB = Math.round(recordedFile.size / 1024); recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`; } function discardRecording() { recordedFile = null; recordedChunks = []; recordResult.classList.add("hidden"); recordToggleLabel.textContent = "Aufnahme starten"; recordFilename.textContent = ""; } recordToggle.addEventListener("click", startRecording); recPauseBtn.addEventListener("click", pauseRecording); recStopBtn.addEventListener("click", stopRecording); recordDiscardBtn.addEventListener("click", discardRecording); // ─── Init ────────────────────────────────────────────────────────────────── bootstrap(); checkMac(); setInterval(() => { if (currentUser) loadJobs(); }, 3000); setInterval(checkMac, 15000);