// ─── 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 jobsCards = document.getElementById("jobs-cards");
const adminToggle = document.getElementById("admin-toggle");
const adminCollapsible = document.getElementById("admin-card");
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 retryBtn = job.status !== "done"
? ``
: "";
const statusCell = `
${escapeHtml(job.status)}
${job.error ? `
${escapeHtml(job.error)}
` : ""}
${retryBtn}
`;
tr.innerHTML = `
#${job.id} |
${escapeHtml(title)} ${profileTag} ${escapeHtml(job.original_name)}${ownerLine} |
${statusCell} |
${job.progress}% |
${fmtDate(job.updated_at)} |
`;
tr.appendChild(dlCell);
return tr;
}
function card(job) {
const div = document.createElement("div");
div.className = "jc";
const title = job.title || job.original_name;
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
const profileTag = showProfileTag
? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}`
: "";
const ownerLine = currentUser?.is_admin && job.owner_username
? `von ${escapeHtml(job.owner_username)}
` : "";
const dlButtons = DOWNLOADS.map(({ kind, label, title: tip }) => {
const dis = !job.has[kind];
const href = dis ? "" : `href="/api/jobs/${job.id}/download/${kind}"`;
return `${label}`;
}).join("");
div.innerHTML = `
${escapeHtml(title)} ${profileTag}
${escapeHtml(job.original_name)} · #${job.id}
${ownerLine}
${escapeHtml(job.status)}
${job.error ? `${escapeHtml(job.error)}
` : ""}
${job.status !== "done" ? `` : ""}
aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}
${dlButtons}
`;
return div;
}
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 = "";
jobsCards.innerHTML = "";
if (!jobs.length) {
jobsBody.innerHTML = '| Noch keine Jobs. |
';
jobsCards.innerHTML = 'Noch keine Jobs.
';
return;
}
jobs.forEach((j) => {
jobsBody.appendChild(row(j));
jobsCards.appendChild(card(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);
// Admin-Toggle (klappbar auf Mobile, immer offen auf Desktop)
adminToggle.addEventListener("click", () => {
if (window.matchMedia("(max-width: 700px)").matches) {
adminCollapsible.classList.toggle("open");
}
});
// Retry-Button: funktioniert für Tabelle und Karten gleichzeitig (Event-Delegation)
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest(".btn-retry");
if (!btn) return;
ev.preventDefault();
const id = btn.dataset.id;
if (!confirm("Verarbeitung erneut starten?\nDas Transkript bleibt erhalten — nur Zusammenfassung, Protokoll und DOCX werden neu erzeugt.")) return;
btn.disabled = true;
btn.textContent = "wird gestartet …";
try {
const r = await api(`/api/jobs/${id}/retry`, { method: "POST" });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Konnte nicht neu starten.");
return;
}
loadJobs();
} catch (e) {
alert("Netzwerkfehler beim Neustart.");
}
});
// ─── Init ──────────────────────────────────────────────────────────────────
bootstrap();
checkMac();
setInterval(() => { if (currentUser) loadJobs(); }, 3000);
setInterval(checkMac, 15000);