feat(admin): vollständiges Admin-Panel in der Web-UI (Phase 1)
Ersetzt das rudimentäre Nutzer-Dropdown durch ein vollwertiges
Admin-Panel-Overlay mit 5 Tabs:
Nutzer: Anlegen (Token einmalig), Umbenennen, Token-Reset,
Löschen, Erinnerungen verwalten (anzeigen/add/delete)
Gespräche: Nutzer → Sessions → Transkript (Chat-Bubble-Ansicht)
Notfälle: Tabelle aller emergency_events mit Zeitstempel/Kategorie
Status: Gateway-Status, Provider-Config, Laufzeit-Metriken
Metriken: Nutzungsstatistik pro Nutzer + Gesamtsumme
Backend: 7 neue Admin-Endpunkte in admin.py, 5 neue Store-Methoden.
Design: Tailwind CSS, Tag/Nacht-Modus, responsiv.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e48ab6d66
commit
b38992cf28
4 changed files with 710 additions and 34 deletions
|
|
@ -101,3 +101,54 @@ async def list_users():
|
|||
}
|
||||
for u in get_store().list_users()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}/memories", dependencies=[Depends(require_admin)])
|
||||
async def get_user_memories(user_id: str):
|
||||
"""Gibt alle Erinnerungen eines Nutzers zurueck."""
|
||||
store = get_store()
|
||||
if store.get_user(user_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||
return [
|
||||
{"id": m.id, "content": m.content, "created_at": m.created_at}
|
||||
for m in store.get_memories(user_id)
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/admin/users/{user_id}/memories/{memory_id}", dependencies=[Depends(require_admin)])
|
||||
async def delete_user_memory(user_id: str, memory_id: int):
|
||||
"""Loescht eine einzelne Erinnerung eines Nutzers."""
|
||||
deleted = get_store().delete_memory(user_id, memory_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Erinnerung nicht gefunden.")
|
||||
return {"deleted": memory_id}
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}/sessions", dependencies=[Depends(require_admin)])
|
||||
async def list_user_sessions(user_id: str):
|
||||
"""Listet alle Sessions eines Nutzers (neueste zuerst)."""
|
||||
return get_store().list_sessions_for_user(user_id)
|
||||
|
||||
|
||||
@router.get("/admin/sessions/{session_id}/messages", dependencies=[Depends(require_admin)])
|
||||
async def get_session_messages(session_id: str, limit: int = 200):
|
||||
"""Gibt alle Nachrichten einer Session zurueck (Gespraechs-Browser)."""
|
||||
return get_store().get_messages_for_session(session_id, limit)
|
||||
|
||||
|
||||
@router.get("/admin/emergency-events", dependencies=[Depends(require_admin)])
|
||||
async def list_emergency_events(limit: int = 50):
|
||||
"""Listet alle protokollierten Notfall-Ereignisse (neueste zuerst)."""
|
||||
return get_store().list_emergency_events(limit)
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}/usage", dependencies=[Depends(require_admin)])
|
||||
async def get_user_usage(user_id: str):
|
||||
"""Nutzungsstatistik eines Nutzers (letzte 30 Tage)."""
|
||||
return get_store().get_usage_for_user(user_id)
|
||||
|
||||
|
||||
@router.get("/admin/usage", dependencies=[Depends(require_admin)])
|
||||
async def get_all_usage():
|
||||
"""Aggregierte Nutzungsstatistik aller Nutzer."""
|
||||
return get_store().get_all_usage()
|
||||
|
|
|
|||
67
app/store.py
67
app/store.py
|
|
@ -137,6 +137,21 @@ class Store(ABC):
|
|||
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
|
||||
"""Protokolliert ein erkanntes Notfall-Signal (sensibel!)."""
|
||||
|
||||
@abstractmethod
|
||||
def list_sessions_for_user(self, user_id: str) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def list_emergency_events(self, limit: int = 50) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_usage_for_user(self, user_id: str) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_all_usage(self) -> list[dict]: ...
|
||||
|
||||
|
||||
class SQLiteStore(Store):
|
||||
def __init__(self, db_path: str):
|
||||
|
|
@ -484,3 +499,55 @@ class SQLiteStore(Store):
|
|||
" VALUES (?, ?, ?, ?)",
|
||||
(user_id, category, snippet, _now()),
|
||||
)
|
||||
|
||||
# ----- Admin-Abfragen ---------------------------------------------------
|
||||
def list_sessions_for_user(self, user_id: str) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT s.id, s.created_at, s.updated_at,"
|
||||
" (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS msg_count"
|
||||
" FROM sessions s WHERE s.user_id = ?"
|
||||
" ORDER BY s.updated_at DESC LIMIT 50",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT role, content, created_at FROM messages"
|
||||
" WHERE session_id = ? ORDER BY id LIMIT ?",
|
||||
(session_id, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def list_emergency_events(self, limit: int = 50) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT e.id, e.user_id, e.category, e.snippet, e.created_at,"
|
||||
" COALESCE(u.display_name, e.user_id) AS display_name"
|
||||
" FROM emergency_events e LEFT JOIN users u ON u.id = e.user_id"
|
||||
" ORDER BY e.id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_usage_for_user(self, user_id: str) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT day, requests, units FROM usage"
|
||||
" WHERE user_id = ? ORDER BY day DESC LIMIT 30",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_all_usage(self) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name,"
|
||||
" SUM(u.requests) AS total_requests, SUM(u.units) AS total_units,"
|
||||
" MAX(u.day) AS last_active"
|
||||
" FROM usage u LEFT JOIN users usr ON usr.id = u.user_id"
|
||||
" GROUP BY u.user_id ORDER BY total_requests DESC",
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
|
|
|||
496
app/web/app.js
496
app/web/app.js
|
|
@ -61,24 +61,7 @@ async function ensureSession() {
|
|||
if (!sessionId) sessionId = "web-anon";
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const ul = $("#user-list");
|
||||
ul.innerHTML = "<li class='muted'>lade …</li>";
|
||||
try {
|
||||
const res = await fetch("/api/admin/users");
|
||||
if (!res.ok) { ul.innerHTML = "<li class='muted'>kein Zugriff</li>"; return; }
|
||||
const users = await res.json();
|
||||
ul.innerHTML = "";
|
||||
for (const u of users) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = (u.external_id || u.display_name) + " (" + u.user_id.slice(0, 8) + ")";
|
||||
ul.appendChild(li);
|
||||
}
|
||||
if (!users.length) ul.innerHTML = "<li class='muted'>keine Nutzer</li>";
|
||||
} catch (e) {
|
||||
ul.innerHTML = "<li class='muted'>Fehler</li>";
|
||||
}
|
||||
}
|
||||
// loadUsers() ersetzt durch Admin-Panel (siehe unten)
|
||||
|
||||
// ---------- Nachrichten-UI ----------
|
||||
// Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar.
|
||||
|
|
@ -312,8 +295,7 @@ micBtn.addEventListener("click", () => {
|
|||
}
|
||||
});
|
||||
|
||||
$("#reload-users").addEventListener("click", loadUsers);
|
||||
$("#admin-toggle").addEventListener("click", () => $("#admin").classList.toggle("hidden"));
|
||||
$("#admin-toggle").addEventListener("click", openAdminPanel);
|
||||
|
||||
// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt.
|
||||
const themeBtn = $("#theme-toggle");
|
||||
|
|
@ -340,3 +322,477 @@ window.addEventListener("resize", scrollToBottom);
|
|||
promptEl.addEventListener("focus", () => setTimeout(scrollToBottom, 300));
|
||||
|
||||
loadMe();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ADMIN PANEL
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---------- Hilfsfunktionen ----------
|
||||
function escHtml(str) {
|
||||
if (!str) return "";
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
async function adminFetch(url, method = "GET", body = null) {
|
||||
try {
|
||||
const opts = { method, headers: { "Content-Type": "application/json" } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
if (method === "DELETE" && res.status === 204) return {};
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) { console.error("Admin API", res.status, data); return null; }
|
||||
return data;
|
||||
} catch (e) { console.error("adminFetch", e); return null; }
|
||||
}
|
||||
|
||||
// ---------- Panel öffnen / schließen ----------
|
||||
function openAdminPanel() {
|
||||
$("#admin-panel").classList.remove("hidden");
|
||||
switchAdminTab("users");
|
||||
}
|
||||
|
||||
$("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden"));
|
||||
|
||||
// ---------- Tab-Umschalter ----------
|
||||
function switchAdminTab(name) {
|
||||
document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden"));
|
||||
$("#admin-tab-" + name).classList.remove("hidden");
|
||||
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) => {
|
||||
const active = btn.dataset.tab === name;
|
||||
btn.classList.toggle("border-blue-600", active);
|
||||
btn.classList.toggle("text-blue-600", active);
|
||||
btn.classList.toggle("dark:border-blue-400", active);
|
||||
btn.classList.toggle("dark:text-blue-400", active);
|
||||
btn.classList.toggle("border-transparent", !active);
|
||||
btn.classList.toggle("text-slate-500", !active);
|
||||
});
|
||||
|
||||
if (name === "users") loadAdminUsers();
|
||||
if (name === "chat") loadChatUsers();
|
||||
if (name === "emergency") loadEmergencyEvents();
|
||||
if (name === "status") loadStatus();
|
||||
if (name === "metrics") loadMetrics();
|
||||
}
|
||||
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) =>
|
||||
btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab))
|
||||
);
|
||||
$("#load-emergency").addEventListener("click", loadEmergencyEvents);
|
||||
$("#load-status").addEventListener("click", loadStatus);
|
||||
$("#load-metrics").addEventListener("click", loadMetrics);
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: NUTZER
|
||||
// ════════════════════════════════════════
|
||||
async function loadAdminUsers() {
|
||||
const container = $("#admin-user-cards");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">lade …</p>';
|
||||
const users = await adminFetch("/api/admin/users");
|
||||
if (!users) { container.innerHTML = '<p class="text-sm text-red-500 text-center py-4">Fehler beim Laden.</p>'; return; }
|
||||
if (!users.length) { container.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">Noch keine Nutzer.</p>'; return; }
|
||||
container.innerHTML = "";
|
||||
users.forEach((u) => container.appendChild(buildUserCard(u)));
|
||||
}
|
||||
|
||||
$("#create-user-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const nameEl = $("#new-user-name");
|
||||
const name = nameEl.value.trim();
|
||||
if (!name) return;
|
||||
const data = await adminFetch("/api/admin/users", "POST", { display_name: name });
|
||||
if (data?.token) {
|
||||
$("#new-token-value").textContent = data.token;
|
||||
$("#new-token-box").classList.remove("hidden");
|
||||
nameEl.value = "";
|
||||
loadAdminUsers();
|
||||
}
|
||||
});
|
||||
|
||||
function buildUserCard(u) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
|
||||
|
||||
const badgeSSO = u.external_id
|
||||
? `<span class="rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs px-2 py-0.5">SSO</span>` : "";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start gap-3 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-0.5">
|
||||
<span class="font-semibold text-slate-900 dark:text-slate-100 user-dn">${escHtml(u.display_name)}</span>
|
||||
${badgeSSO}
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 font-mono">${u.user_id.slice(0, 8)}…${u.external_id ? " · " + escHtml(u.external_id) : ""}</p>
|
||||
<p class="text-xs text-slate-400">seit ${fmtDate(u.created_at)}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button class="btn-rename text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Umbenennen</button>
|
||||
<button class="btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Token-Reset</button>
|
||||
<button class="btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token-Anzeige nach Reset -->
|
||||
<div class="token-box hidden mt-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 p-3">
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300 font-medium mb-1">Neuer Token (einmalig — jetzt kopieren!):</p>
|
||||
<code class="token-val text-xs font-mono break-all text-amber-900 dark:text-amber-100 select-all"></code>
|
||||
</div>
|
||||
|
||||
<!-- Umbenenn-Formular -->
|
||||
<div class="rename-box hidden mt-3">
|
||||
<form class="flex gap-2">
|
||||
<input type="text" class="rename-inp flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value="${escHtml(u.display_name)}" />
|
||||
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 transition-colors">Speichern</button>
|
||||
<button type="button" class="rename-cancel rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Abbrechen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Erinnerungen -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
|
||||
<span class="mem-arrow">▶</span> Erinnerungen
|
||||
</button>
|
||||
<div class="mem-section hidden mt-2 space-y-1.5">
|
||||
<div class="mem-list"></div>
|
||||
<form class="mem-add-form flex gap-2 mt-2">
|
||||
<input type="text" placeholder="Neue Erinnerung …"
|
||||
class="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors">Hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const uid = u.user_id;
|
||||
|
||||
// Umbenennen
|
||||
card.querySelector(".btn-rename").addEventListener("click", () =>
|
||||
card.querySelector(".rename-box").classList.toggle("hidden")
|
||||
);
|
||||
card.querySelector(".rename-cancel").addEventListener("click", () =>
|
||||
card.querySelector(".rename-box").classList.add("hidden")
|
||||
);
|
||||
card.querySelector(".rename-box form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = card.querySelector(".rename-inp").value.trim();
|
||||
if (!name) return;
|
||||
const res = await adminFetch(`/api/admin/users/${uid}`, "PUT", { display_name: name });
|
||||
if (res) {
|
||||
card.querySelector(".user-dn").textContent = name;
|
||||
card.querySelector(".rename-box").classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Token-Reset
|
||||
card.querySelector(".btn-token").addEventListener("click", async () => {
|
||||
if (!confirm(`Token für „${u.display_name}" zurücksetzen?\nDer alte Token wird sofort ungültig.`)) return;
|
||||
const data = await adminFetch(`/api/admin/users/${uid}/token`, "POST");
|
||||
if (data?.token) {
|
||||
card.querySelector(".token-val").textContent = data.token;
|
||||
card.querySelector(".token-box").classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Löschen
|
||||
card.querySelector(".btn-delete").addEventListener("click", async () => {
|
||||
if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return;
|
||||
const res = await adminFetch(`/api/admin/users/${uid}`, "DELETE");
|
||||
if (res !== null) card.remove();
|
||||
});
|
||||
|
||||
// Erinnerungen auf-/zuklappen
|
||||
const memBtn = card.querySelector(".btn-mem");
|
||||
const memSection = card.querySelector(".mem-section");
|
||||
const memArrow = card.querySelector(".mem-arrow");
|
||||
memBtn.addEventListener("click", async () => {
|
||||
const open = !memSection.classList.contains("hidden");
|
||||
memSection.classList.toggle("hidden", open);
|
||||
memArrow.textContent = open ? "▶" : "▼";
|
||||
if (!open) await renderMemories(uid, card.querySelector(".mem-list"));
|
||||
});
|
||||
|
||||
// Erinnerung hinzufügen
|
||||
card.querySelector(".mem-add-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const inp = e.target.querySelector("input");
|
||||
const content = inp.value.trim();
|
||||
if (!content) return;
|
||||
const res = await adminFetch(`/api/admin/users/${uid}/memories`, "POST", { content });
|
||||
if (res) { inp.value = ""; await renderMemories(uid, card.querySelector(".mem-list")); }
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function renderMemories(uid, container) {
|
||||
container.innerHTML = '<p class="text-xs text-slate-400">lade …</p>';
|
||||
const mems = await adminFetch(`/api/admin/users/${uid}/memories`);
|
||||
if (!mems?.length) {
|
||||
container.innerHTML = '<p class="text-xs text-slate-400 italic">Keine Erinnerungen vorhanden.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = "";
|
||||
mems.forEach((m) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs";
|
||||
row.innerHTML = `
|
||||
<span class="flex-1 text-slate-700 dark:text-slate-300 leading-relaxed">${escHtml(m.content)}</span>
|
||||
<button class="shrink-0 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-colors font-bold" title="Löschen">✕</button>
|
||||
`;
|
||||
row.querySelector("button").addEventListener("click", async () => {
|
||||
await adminFetch(`/api/admin/users/${uid}/memories/${m.id}`, "DELETE");
|
||||
row.remove();
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: GESPRÄCHE
|
||||
// ════════════════════════════════════════
|
||||
let _chatUser = null;
|
||||
|
||||
async function loadChatUsers() {
|
||||
const list = $("#chat-user-list");
|
||||
list.innerHTML = '<li class="px-3 py-3 text-sm text-slate-400">lade …</li>';
|
||||
const users = await adminFetch("/api/admin/users");
|
||||
if (!users) return;
|
||||
list.innerHTML = "";
|
||||
users.forEach((u) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col";
|
||||
li.innerHTML = `<span class="font-medium truncate">${escHtml(u.display_name)}</span>
|
||||
<span class="text-xs text-slate-400 font-mono truncate">${u.user_id.slice(0,8)}…</span>`;
|
||||
li.addEventListener("click", () => {
|
||||
document.querySelectorAll("#chat-user-list li").forEach((el) =>
|
||||
el.classList.remove("bg-blue-50", "dark:bg-blue-900/20", "text-blue-700", "dark:text-blue-300")
|
||||
);
|
||||
li.classList.add("bg-blue-50", "dark:bg-blue-900/20");
|
||||
_chatUser = u;
|
||||
loadChatSessions(u);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChatSessions(u) {
|
||||
const panel = $("#chat-right-panel");
|
||||
panel.innerHTML = `<p class="p-4 text-sm text-slate-400">lade Sessions für ${escHtml(u.display_name)} …</p>`;
|
||||
const sessions = await adminFetch(`/api/admin/users/${u.user_id}/sessions`);
|
||||
if (!sessions?.length) {
|
||||
panel.innerHTML = '<p class="p-4 text-sm text-slate-400">Keine Sessions gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = `
|
||||
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/50">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wide">Sessions · ${escHtml(u.display_name)}</p>
|
||||
</div>
|
||||
<div id="session-list" class="divide-y divide-slate-100 dark:divide-slate-800/60"></div>
|
||||
`;
|
||||
const list = panel.querySelector("#session-list");
|
||||
sessions.forEach((s) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors";
|
||||
row.innerHTML = `
|
||||
<p class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate">${escHtml(s.id)}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">${fmtDate(s.updated_at)} · ${s.msg_count} Nachrichten</p>
|
||||
`;
|
||||
row.addEventListener("click", () => loadTranscript(s.id));
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTranscript(sessionId) {
|
||||
const panel = $("#chat-right-panel");
|
||||
panel.innerHTML = `<p class="p-4 text-sm text-slate-400">lade Transkript …</p>`;
|
||||
const msgs = await adminFetch(`/api/admin/sessions/${sessionId}/messages`);
|
||||
if (!msgs?.length) {
|
||||
panel.innerHTML = `<div class="p-4">
|
||||
<button class="back-btn text-xs text-blue-600 hover:underline mb-3 block">← zurück zu Sessions</button>
|
||||
<p class="text-sm text-slate-400">Keine Nachrichten.</p></div>`;
|
||||
panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser));
|
||||
return;
|
||||
}
|
||||
|
||||
const bubbles = msgs.map((m) => {
|
||||
const isUser = m.role === "user";
|
||||
return `<div class="flex ${isUser ? "justify-end" : "justify-start"}">
|
||||
<div class="max-w-[80%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap leading-relaxed
|
||||
${isUser
|
||||
? "rounded-br-sm bg-blue-600 text-white"
|
||||
: "rounded-bl-sm border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100"}">
|
||||
${escHtml(m.content)}</div></div>`;
|
||||
}).join("");
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="sticky top-0 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur flex items-center gap-3">
|
||||
<button class="back-btn text-xs text-blue-600 hover:underline">← Sessions</button>
|
||||
<span class="text-xs text-slate-400 font-mono truncate">${escHtml(sessionId)}</span>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">${bubbles}</div>
|
||||
`;
|
||||
panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: NOTFÄLLE
|
||||
// ════════════════════════════════════════
|
||||
async function loadEmergencyEvents() {
|
||||
const container = $("#emergency-content");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
const events = await adminFetch("/api/admin/emergency-events");
|
||||
if (!events) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
|
||||
if (!events.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Keine Notfall-Ereignisse protokolliert.</p>'; return; }
|
||||
container.innerHTML = `
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/60">
|
||||
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="text-left px-4 py-2.5">Zeitpunkt</th>
|
||||
<th class="text-left px-4 py-2.5">Nutzer</th>
|
||||
<th class="text-left px-4 py-2.5">Kategorie</th>
|
||||
<th class="text-left px-4 py-2.5">Textausschnitt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800">
|
||||
${events.map((ev) => `
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
|
||||
<td class="px-4 py-2.5 text-xs text-slate-400 whitespace-nowrap">${fmtDate(ev.created_at)}</td>
|
||||
<td class="px-4 py-2.5 text-xs font-medium">${escHtml(ev.display_name)}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="rounded-full bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-xs px-2.5 py-0.5 font-medium">${escHtml(ev.category)}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-slate-600 dark:text-slate-300 max-w-xs truncate">${escHtml(ev.snippet)}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: STATUS
|
||||
// ════════════════════════════════════════
|
||||
async function loadStatus() {
|
||||
const container = $("#status-content");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
try {
|
||||
const [cfg, met] = await Promise.all([
|
||||
fetch("/api/config").then((r) => r.json()),
|
||||
fetch("/api/metrics").then((r) => r.json()),
|
||||
]);
|
||||
const route = cfg.default_route || {};
|
||||
const counters = met.counters || {};
|
||||
const turnsTotal = counters["turns_total"] || 0;
|
||||
const httpTotal = Object.entries(counters)
|
||||
.filter(([k]) => k.startsWith("http_requests_total"))
|
||||
.reduce((s, [, v]) => s + v, 0);
|
||||
|
||||
const providerRow = (label, val) =>
|
||||
`<dt class="text-slate-500 dark:text-slate-400">${label}</dt>
|
||||
<dd class="font-mono text-xs text-slate-700 dark:text-slate-300">${escHtml(val || "—")}</dd>`;
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Gateway-Status -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-emerald-500 shadow shadow-emerald-300/60"></span>
|
||||
<h3 class="font-semibold text-sm">Gateway</h3>
|
||||
<span class="text-xs text-emerald-600 dark:text-emerald-400 font-medium">läuft</span>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
|
||||
${providerRow("Profil", cfg.profile)}
|
||||
${providerRow("Umgebung", cfg.app_env)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Provider -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h3 class="font-semibold text-sm mb-3">Provider (Standard-Route)</h3>
|
||||
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
|
||||
${providerRow("STT", route.stt_provider)}
|
||||
${providerRow("LLM", route.llm_provider)}
|
||||
${providerRow("TTS", route.tts_provider)}
|
||||
${providerRow("Sprache", route.language)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Laufzeit-Metriken -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h3 class="font-semibold text-sm mb-3">Laufzeit-Metriken</h3>
|
||||
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
|
||||
${providerRow("Turns gesamt", turnsTotal)}
|
||||
${providerRow("HTTP-Anfragen", httpTotal)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Verfügbare Provider -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h3 class="font-semibold text-sm mb-3">Verfügbare Provider</h3>
|
||||
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
|
||||
${providerRow("STT", (cfg.available?.stt_providers || []).join(", "))}
|
||||
${providerRow("LLM", (cfg.available?.llm_providers || []).join(", "))}
|
||||
${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}
|
||||
</dl>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: METRIKEN
|
||||
// ════════════════════════════════════════
|
||||
async function loadMetrics() {
|
||||
const container = $("#metrics-content");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
const data = await adminFetch("/api/admin/usage");
|
||||
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
|
||||
if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; return; }
|
||||
|
||||
const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/60">
|
||||
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="text-left px-4 py-2.5">Nutzer</th>
|
||||
<th class="text-right px-4 py-2.5">Anfragen gesamt</th>
|
||||
<th class="text-right px-4 py-2.5">Einheiten</th>
|
||||
<th class="text-right px-4 py-2.5">Letzte Aktivität</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800">
|
||||
${data.map((u) => `
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
|
||||
<td class="px-4 py-2.5 font-medium">${escHtml(u.display_name)}</td>
|
||||
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_requests ?? 0}</td>
|
||||
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_units ?? 0}</td>
|
||||
<td class="px-4 py-2.5 text-right text-xs text-slate-400">${u.last_active || "—"}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
<tfoot class="border-t-2 border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/60">
|
||||
<tr class="text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
<td class="px-4 py-2">Gesamt</td>
|
||||
<td class="px-4 py-2 text-right font-mono">${totalReq}</td>
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = { darkMode: "class" };</script>
|
||||
<script>
|
||||
// Theme vor dem Rendern setzen (kein Flackern): manuell > System.
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("theme");
|
||||
|
|
@ -19,6 +18,117 @@
|
|||
</head>
|
||||
<body class="h-[100dvh] flex flex-col bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-100">
|
||||
|
||||
<!-- ═══════════════════ ADMIN PANEL OVERLAY ═══════════════════ -->
|
||||
<div id="admin-panel" class="hidden fixed inset-0 z-50 flex flex-col bg-slate-50 dark:bg-slate-900">
|
||||
|
||||
<!-- Admin Header -->
|
||||
<header class="shrink-0 h-14 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur">
|
||||
<span class="text-lg">⚙️</span>
|
||||
<h2 class="flex-1 font-semibold text-base">Admin-Bereich</h2>
|
||||
<button id="admin-close" title="Schließen"
|
||||
class="h-9 w-9 grid place-items-center rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">✕</button>
|
||||
</header>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="shrink-0 flex border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 overflow-x-auto">
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400" data-tab="users">👥 Nutzer</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="chat">💬 Gespräche</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notfälle</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="status">📡 Status</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="metrics">📊 Metriken</button>
|
||||
</nav>
|
||||
|
||||
<!-- Tab Content Container -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
|
||||
<!-- ── Tab: Nutzer ── -->
|
||||
<div id="admin-tab-users" class="h-full overflow-y-auto p-4">
|
||||
<div class="max-w-3xl mx-auto space-y-4">
|
||||
|
||||
<!-- Nutzer anlegen -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Nutzer anlegen</h3>
|
||||
<form id="create-user-form" class="flex gap-2">
|
||||
<input id="new-user-name" type="text" placeholder="Anzeigename" required
|
||||
class="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 font-medium transition-colors">Anlegen</button>
|
||||
</form>
|
||||
<div id="new-token-box" class="hidden mt-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 p-3">
|
||||
<p class="text-xs text-emerald-700 dark:text-emerald-400 mb-1 font-medium">Token (nur einmal sichtbar — jetzt kopieren!):</p>
|
||||
<code id="new-token-value" class="text-xs font-mono break-all text-emerald-800 dark:text-emerald-200 select-all"></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutzerliste -->
|
||||
<div id="admin-user-cards" class="space-y-3">
|
||||
<p class="text-sm text-slate-400 text-center py-4">lade …</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab: Gespräche ── -->
|
||||
<div id="admin-tab-chat" class="hidden h-full flex overflow-hidden">
|
||||
<!-- Linke Spalte: Nutzerliste -->
|
||||
<div class="w-48 md:w-60 shrink-0 border-r border-slate-200 dark:border-slate-700 flex flex-col">
|
||||
<div class="px-3 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-slate-100/60 dark:bg-slate-800/60">
|
||||
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Nutzer</p>
|
||||
</div>
|
||||
<ul id="chat-user-list" class="flex-1 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800/60"></ul>
|
||||
</div>
|
||||
<!-- Rechte Spalte: Sessions & Transkript -->
|
||||
<div id="chat-right-panel" class="flex-1 overflow-y-auto">
|
||||
<p class="p-6 text-sm text-slate-400">← Nutzer auswählen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab: Notfälle ── -->
|
||||
<div id="admin-tab-emergency" class="hidden h-full overflow-y-auto p-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-base">Notfall-Ereignisse</h3>
|
||||
<button id="load-emergency"
|
||||
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="emergency-content">
|
||||
<p class="text-sm text-slate-400">lade …</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab: Status ── -->
|
||||
<div id="admin-tab-status" class="hidden h-full overflow-y-auto p-4">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-base">System-Status</h3>
|
||||
<button id="load-status"
|
||||
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="status-content" class="space-y-4">
|
||||
<p class="text-sm text-slate-400">lade …</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab: Metriken ── -->
|
||||
<div id="admin-tab-metrics" class="hidden h-full overflow-y-auto p-4">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-base">Nutzungsstatistik</h3>
|
||||
<button id="load-metrics"
|
||||
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="metrics-content">
|
||||
<p class="text-sm text-slate-400">lade …</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /Tab Content Container -->
|
||||
</div><!-- /Admin Panel -->
|
||||
|
||||
|
||||
<!-- ═══════════════════ CHAT INTERFACE ═══════════════════ -->
|
||||
<header class="shrink-0 h-14 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur">
|
||||
<h1 class="flex-1 truncate font-semibold text-base">Voice Assistant</h1>
|
||||
<select id="tts" title="Stimme"
|
||||
|
|
@ -27,10 +137,10 @@
|
|||
<option value="chatterbox">Hohe Qualität</option>
|
||||
<option value="openrouter">Cloud</option>
|
||||
</select>
|
||||
<button id="admin-toggle" title="Nutzer"
|
||||
class="hidden h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600">👥</button>
|
||||
<button id="admin-toggle" title="Admin"
|
||||
class="hidden h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">⚙️</button>
|
||||
<button id="theme-toggle" title="Tag/Nacht"
|
||||
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600">🌙</button>
|
||||
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">🌙</button>
|
||||
</header>
|
||||
|
||||
<div class="shrink-0 flex items-center justify-between px-4 py-1 text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
|
|
@ -38,14 +148,6 @@
|
|||
<a id="logout" class="hidden hover:underline" href="#">Abmelden</a>
|
||||
</div>
|
||||
|
||||
<div id="admin" class="hidden shrink-0 px-4 py-2 border-b border-slate-200 dark:border-slate-700 bg-slate-100/60 dark:bg-slate-800/60">
|
||||
<div class="flex items-center gap-2 mb-1 text-sm">
|
||||
<span class="font-medium">Nutzer</span>
|
||||
<button id="reload-users" class="text-xs rounded border border-slate-300 dark:border-slate-600 px-2 py-0.5">aktualisieren</button>
|
||||
</div>
|
||||
<ul id="user-list" class="text-xs text-slate-600 dark:text-slate-300 space-y-0.5 max-h-32 overflow-auto"></ul>
|
||||
</div>
|
||||
|
||||
<main id="messages" class="flex-1 overflow-y-auto px-4 py-4 space-y-3 w-full max-w-3xl mx-auto"></main>
|
||||
|
||||
<footer class="shrink-0 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.6rem)]">
|
||||
|
|
@ -55,11 +157,11 @@
|
|||
<input id="prompt" type="text" autocomplete="off" placeholder="Nachricht …"
|
||||
class="flex-1 rounded-xl border border-slate-300 dark:border-slate-600 bg-transparent px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit" id="send"
|
||||
class="shrink-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 font-medium">Senden</button>
|
||||
class="shrink-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 font-medium transition-colors">Senden</button>
|
||||
</form>
|
||||
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=7"></script>
|
||||
<script src="/app.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue