// ─── 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"); 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(); if (!audioInput.files.length) return; const fd = new FormData(); fd.append("audio", audioInput.files[0]); 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(); 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."); } } }); // ─── Init ────────────────────────────────────────────────────────────────── bootstrap(); checkMac(); setInterval(() => { if (currentUser) loadJobs(); }, 3000); setInterval(checkMac, 15000);