diff --git a/api/router.py b/api/router.py
index 9430e5e..ce2dad2 100644
--- a/api/router.py
+++ b/api/router.py
@@ -279,6 +279,15 @@ async def create_combined_source(body: dict, user: dict = Depends(current_user))
return {"device": "transkriptor-combined.monitor", "module_ids": ids}
+@router.get("/settings")
+async def settings_page_route(user: dict = Depends(current_user)):
+ from fastapi.responses import FileResponse, RedirectResponse
+ from pathlib import Path
+ if not user.get("is_admin"):
+ return RedirectResponse("/")
+ return FileResponse(str(Path(__file__).parent.parent / "frontend" / "settings.html"))
+
+
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
from auth import get_user_for_token
diff --git a/frontend/settings.html b/frontend/settings.html
new file mode 100644
index 0000000..2e2582d
--- /dev/null
+++ b/frontend/settings.html
@@ -0,0 +1,101 @@
+
+
+
+
+
+ tüit Transkriptor — Einstellungen
+
+
+
+
+
+
+
+
+ Audio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/settings.js b/frontend/settings.js
new file mode 100644
index 0000000..b5fbcff
--- /dev/null
+++ b/frontend/settings.js
@@ -0,0 +1,108 @@
+const token = sessionStorage.getItem('token');
+function authHeaders() {
+ return token ? { 'Authorization': 'Bearer ' + token } : {};
+}
+function apiFetch(url, options) {
+ options = options || {};
+ return fetch(url, Object.assign({}, options, {
+ headers: Object.assign({'Content-Type': 'application/json'}, authHeaders(), options.headers || {}),
+ }));
+}
+
+let _devices = [];
+
+function showToast(msg) {
+ const t = document.getElementById('toast');
+ t.textContent = msg;
+ t.classList.add('show');
+ setTimeout(function() { t.classList.remove('show'); }, 2500);
+}
+
+async function loadDevices() {
+ const r = await apiFetch('/audio/devices');
+ if (!r.ok) return;
+ _devices = await r.json();
+ const sel = document.getElementById('audio-device');
+ const current = sel.value;
+ sel.replaceChildren(new Option('Systemstandard', ''));
+ _devices.forEach(function(d) { sel.appendChild(new Option(d.name, d.name)); });
+ if (current) sel.value = current;
+ ['combined-mic', 'combined-monitor'].forEach(function(id) {
+ const el = document.getElementById(id);
+ el.replaceChildren();
+ _devices.forEach(function(d) { el.appendChild(new Option(d.name, d.name)); });
+ });
+}
+
+async function loadOllamaModels(baseUrl, current) {
+ try {
+ const r = await fetch(baseUrl + '/api/tags');
+ if (!r.ok) return;
+ const data = await r.json();
+ const sel = document.getElementById('ollama-model');
+ sel.replaceChildren();
+ (data.models || []).forEach(function(m) { sel.appendChild(new Option(m.name, m.name)); });
+ if (current) sel.value = current;
+ } catch(e) {}
+}
+
+async function loadConfig() {
+ const r = await apiFetch('/config');
+ if (!r.ok) return;
+ const cfg = await r.json();
+ document.getElementById('audio-device').value = (cfg.audio && cfg.audio.device) || '';
+ document.getElementById('whisper-url').value = (cfg.whisper && cfg.whisper.base_url) || '';
+ document.getElementById('whisper-model').value = (cfg.whisper && cfg.whisper.model) || 'large-v3';
+ const ollamaUrl = (cfg.ollama && cfg.ollama.base_url) || 'http://localhost:11434';
+ document.getElementById('ollama-url').value = ollamaUrl;
+ await loadOllamaModels(ollamaUrl, cfg.ollama && cfg.ollama.model);
+}
+
+document.getElementById('refresh-devices-btn').addEventListener('click', loadDevices);
+
+document.getElementById('create-combined-btn').addEventListener('click', function() {
+ document.getElementById('combined-form').classList.toggle('visible');
+});
+document.getElementById('combined-cancel-btn').addEventListener('click', function() {
+ document.getElementById('combined-form').classList.remove('visible');
+});
+document.getElementById('combined-confirm-btn').addEventListener('click', async function() {
+ const mic = document.getElementById('combined-mic').value;
+ const monitor = document.getElementById('combined-monitor').value;
+ const r = await apiFetch('/audio/combined', {
+ method: 'POST',
+ body: JSON.stringify({ mic: mic, monitor: monitor }),
+ });
+ if (!r.ok) { showToast('Fehler beim Erstellen'); return; }
+ const data = await r.json();
+ showToast('Erstellt: ' + data.device);
+ document.getElementById('combined-form').classList.remove('visible');
+ await loadDevices();
+ document.getElementById('audio-device').value = data.device;
+});
+
+document.getElementById('ollama-url').addEventListener('change', function(e) {
+ loadOllamaModels(e.target.value, document.getElementById('ollama-model').value);
+});
+
+document.getElementById('save-btn').addEventListener('click', async function() {
+ const body = {
+ audio: { device: document.getElementById('audio-device').value },
+ whisper: {
+ base_url: document.getElementById('whisper-url').value,
+ model: document.getElementById('whisper-model').value,
+ },
+ ollama: {
+ base_url: document.getElementById('ollama-url').value,
+ model: document.getElementById('ollama-model').value,
+ },
+ };
+ const r = await apiFetch('/config', { method: 'PUT', body: JSON.stringify(body) });
+ if (r.ok) { showToast('Gespeichert'); } else { showToast('Fehler beim Speichern'); }
+});
+
+(async function() {
+ if (!token) { location.href = '/login'; return; }
+ await loadDevices();
+ await loadConfig();
+})();
diff --git a/main.py b/main.py
index 095e890..acd4f8b 100644
--- a/main.py
+++ b/main.py
@@ -44,6 +44,11 @@ async def logo():
return FileResponse(str(FRONTEND_DIR / "logo.svg"), media_type="image/svg+xml")
+@app.get("/settings.js")
+async def settingsjs():
+ return FileResponse(str(FRONTEND_DIR / "settings.js"))
+
+
# ── PID file ───────────────────────────────────────────────────────────────────
def write_pid(pid_path: str):