diff --git a/Overlord-Server/public/assets/chat-client.js b/Overlord-Server/public/assets/chat-client.js
new file mode 100644
index 0000000..f9919ab
--- /dev/null
+++ b/Overlord-Server/public/assets/chat-client.js
@@ -0,0 +1,179 @@
+const UNREAD_KEY = "overlord_chat_unread";
+const MUTE_KEY = "overlord_chat_mute";
+
+let ws = null;
+let canWrite = false;
+let currentUserId = 0;
+let messageHandlers = new Set();
+let unreadHandlers = new Set();
+let statusHandlers = new Set();
+let readyHandlers = new Set();
+
+if (localStorage.getItem(UNREAD_KEY) === null) {
+ localStorage.setItem(UNREAD_KEY, "0");
+}
+if (localStorage.getItem(MUTE_KEY) === null) {
+ localStorage.setItem(MUTE_KEY, "none");
+}
+
+function emitStatus(status) {
+ for (const handler of statusHandlers) {
+ try { handler(status); } catch {}
+ }
+}
+
+function emitReady(history, writable, userId) {
+ canWrite = writable;
+ currentUserId = userId || 0;
+ for (const handler of readyHandlers) {
+ try { handler(history, writable); } catch {}
+ }
+}
+
+function emitMessage(msg) {
+ for (const handler of messageHandlers) {
+ try { handler(msg); } catch {}
+ }
+}
+
+export function getUnreadCount() {
+ return Number(localStorage.getItem(UNREAD_KEY) || "0");
+}
+
+export function setUnreadCount(value) {
+ const next = Math.max(0, Number(value) || 0);
+ localStorage.setItem(UNREAD_KEY, String(next));
+ for (const handler of unreadHandlers) {
+ try { handler(next); } catch {}
+ }
+}
+
+export function incrementUnread() {
+ setUnreadCount(getUnreadCount() + 1);
+}
+
+export function resetUnread() {
+ setUnreadCount(0);
+}
+
+export function getMuteMode() {
+ return localStorage.getItem(MUTE_KEY) || "none";
+}
+
+export function setMuteMode(mode) {
+ const valid = ["none", "mute", "mute_collapse", "hide"];
+ if (!valid.includes(mode)) return;
+ localStorage.setItem(MUTE_KEY, mode);
+}
+
+let audioCtx = null;
+
+function playNotificationSound() {
+ const mute = getMuteMode();
+ if (mute !== "none") return;
+ try {
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ const osc = audioCtx.createOscillator();
+ const gain = audioCtx.createGain();
+ osc.connect(gain);
+ gain.connect(audioCtx.destination);
+ osc.type = "sine";
+ osc.frequency.setValueAtTime(880, audioCtx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(440, audioCtx.currentTime + 0.15);
+ gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
+ osc.start(audioCtx.currentTime);
+ osc.stop(audioCtx.currentTime + 0.2);
+ } catch {}
+}
+
+export function onMessage(cb) { messageHandlers.add(cb); return () => messageHandlers.delete(cb); }
+export function onUnreadChanged(cb) { unreadHandlers.add(cb); return () => unreadHandlers.delete(cb); }
+export function onStatusChanged(cb) { statusHandlers.add(cb); return () => statusHandlers.delete(cb); }
+export function onReady(cb) { readyHandlers.add(cb); return () => readyHandlers.delete(cb); }
+
+export function getCanWrite() { return canWrite; }
+
+export function sendMessage(text) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
+ if (!canWrite) return false;
+ const trimmed = (text || "").trim();
+ if (!trimmed || trimmed.length > 2000) return false;
+ ws.send(JSON.stringify({ type: "chat_send", message: trimmed }));
+ return true;
+}
+
+export async function fetchHistory(before, limit = 50) {
+ const params = new URLSearchParams();
+ if (before) params.set("before", String(before));
+ params.set("limit", String(limit));
+ try {
+ const res = await fetch(`/api/chat/history?${params.toString()}`);
+ if (!res.ok) return { messages: [], canWrite: false };
+ return await res.json();
+ } catch {
+ return { messages: [], canWrite: false };
+ }
+}
+
+function handleWsMessage(payload) {
+ if (!payload || typeof payload.type !== "string") return;
+
+ if (payload.type === "chat_ready") {
+ emitReady(payload.history || [], !!payload.canWrite, payload.userId);
+ return;
+ }
+
+ if (payload.type === "chat_message") {
+ emitMessage(payload);
+ const isOwnMessage = currentUserId && payload.userId === currentUserId;
+ if (!isOwnMessage) {
+ const mute = getMuteMode();
+ if (mute === "none") {
+ incrementUnread();
+ playNotificationSound();
+ }
+ }
+ return;
+ }
+
+ if (payload.type === "chat_error") {
+ console.warn("[chat] server error:", payload.error);
+ }
+}
+
+function connect() {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/chat/ws`;
+ ws = new WebSocket(wsUrl);
+
+ ws.onopen = () => {
+ emitStatus("connected");
+ };
+
+ ws.onmessage = (event) => {
+ if (typeof event.data === "string") {
+ try {
+ handleWsMessage(JSON.parse(event.data));
+ } catch {}
+ return;
+ }
+ };
+
+ ws.onerror = () => {
+ emitStatus("error");
+ };
+
+ ws.onclose = () => {
+ emitStatus("disconnected");
+ setTimeout(connect, 3000);
+ };
+}
+
+let started = false;
+
+export function start() {
+ if (started) return;
+ started = true;
+ connect();
+}
diff --git a/Overlord-Server/public/assets/chat-widget.js b/Overlord-Server/public/assets/chat-widget.js
new file mode 100644
index 0000000..5ef025f
--- /dev/null
+++ b/Overlord-Server/public/assets/chat-widget.js
@@ -0,0 +1,389 @@
+import * as chatClient from "./chat-client.js";
+
+let panelOpen = false;
+let initialized = false;
+let bubble = null;
+let panel = null;
+let messageList = null;
+let inputArea = null;
+let sendBtn = null;
+let muteDropdown = null;
+let muteDropdownMenu = null;
+let muteDropdownLabel = null;
+let oldestTimestamp = null;
+let loadingMore = false;
+let hasMore = true;
+
+const ROLE_COLORS = {
+ admin: "bg-rose-500/80 text-white",
+ operator: "bg-sky-500/80 text-white",
+ viewer: "bg-zinc-500/80 text-white",
+};
+
+function escapeHtml(str) {
+ const div = document.createElement("div");
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+function formatTime(ts) {
+ const d = new Date(ts);
+ const now = new Date();
+ const sameDay = d.toDateString() === now.toDateString();
+ if (sameDay) {
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ }
+ return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
+ d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
+
+function createMessageEl(msg) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "flex flex-col gap-0.5 px-3 py-1.5 hover:bg-zinc-800/40 transition-colors";
+ wrapper.dataset.msgId = msg.id;
+ wrapper.dataset.ts = msg.createdAt;
+
+ const header = document.createElement("div");
+ header.className = "flex items-center gap-2 text-xs";
+
+ const badge = document.createElement("span");
+ badge.className = `px-1.5 py-0.5 rounded text-[10px] font-medium ${ROLE_COLORS[msg.userRole] || ROLE_COLORS.viewer}`;
+ badge.textContent = msg.userRole;
+
+ const name = document.createElement("span");
+ name.className = "font-semibold text-slate-200";
+ name.textContent = msg.username;
+
+ const onlineCount = typeof msg.onlineClients === "number" ? msg.onlineClients : 0;
+ const clientsBadge = document.createElement("span");
+ clientsBadge.className = "px-1.5 py-0.5 rounded text-[10px] font-medium " +
+ (onlineCount > 0 ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" : "bg-zinc-700/50 text-zinc-500");
+ clientsBadge.textContent = onlineCount > 0 ? `${onlineCount} online` : "0 online";
+
+ const time = document.createElement("span");
+ time.className = "text-slate-500 ml-auto whitespace-nowrap";
+ time.textContent = formatTime(msg.createdAt);
+
+ header.appendChild(badge);
+ header.appendChild(name);
+ header.appendChild(clientsBadge);
+ header.appendChild(time);
+
+ const body = document.createElement("div");
+ body.className = "text-sm text-slate-300 break-words whitespace-pre-wrap pl-0.5";
+ body.textContent = msg.message;
+
+ wrapper.appendChild(header);
+ wrapper.appendChild(body);
+ return wrapper;
+}
+
+function scrollToBottom() {
+ if (messageList) {
+ messageList.scrollTop = messageList.scrollHeight;
+ }
+}
+
+function updateBubbleBadge() {
+ if (!bubble) return;
+ const count = chatClient.getUnreadCount();
+ const badge = bubble.querySelector("[data-chat-badge]");
+ if (!badge) return;
+ if (count > 0) {
+ badge.textContent = count > 99 ? "99+" : String(count);
+ badge.classList.remove("hidden");
+ } else {
+ badge.classList.add("hidden");
+ }
+}
+
+function updateMuteVisibility() {
+ const mute = chatClient.getMuteMode();
+ if (!bubble || !panel) return;
+
+ if (mute === "hide") {
+ bubble.classList.add("hidden");
+ panel.classList.add("hidden");
+ panelOpen = false;
+ } else {
+ bubble.classList.remove("hidden");
+ }
+
+ if (mute === "mute_collapse" && panelOpen) {
+ // auto collapse handled on new messages
+ }
+}
+
+function togglePanel() {
+ if (!panel) return;
+ panelOpen = !panelOpen;
+ if (panelOpen) {
+ panel.classList.remove("hidden");
+ panel.style.opacity = "0";
+ panel.style.transform = "translateY(10px) scale(0.98)";
+ requestAnimationFrame(() => {
+ panel.style.transition = "opacity 0.15s ease, transform 0.15s ease";
+ panel.style.opacity = "1";
+ panel.style.transform = "translateY(0) scale(1)";
+ });
+ chatClient.resetUnread();
+ updateBubbleBadge();
+ scrollToBottom();
+ if (inputArea) inputArea.focus();
+ } else {
+ panel.style.transition = "opacity 0.1s ease, transform 0.1s ease";
+ panel.style.opacity = "0";
+ panel.style.transform = "translateY(10px) scale(0.98)";
+ setTimeout(() => {
+ if (!panelOpen) panel.classList.add("hidden");
+ }, 100);
+ }
+}
+
+async function loadMoreHistory() {
+ if (loadingMore || !hasMore || !messageList) return;
+ loadingMore = true;
+
+ const result = await chatClient.fetchHistory(oldestTimestamp, 50);
+ const msgs = result.messages || [];
+ if (msgs.length === 0) {
+ hasMore = false;
+ loadingMore = false;
+ return;
+ }
+
+ const prevHeight = messageList.scrollHeight;
+ const prevTop = messageList.scrollTop;
+
+ const frag = document.createDocumentFragment();
+ for (const msg of msgs) {
+ frag.appendChild(createMessageEl(msg));
+ if (!oldestTimestamp || msg.createdAt < oldestTimestamp) {
+ oldestTimestamp = msg.createdAt;
+ }
+ }
+
+ messageList.insertBefore(frag, messageList.firstChild);
+
+ const newHeight = messageList.scrollHeight;
+ messageList.scrollTop = prevTop + (newHeight - prevHeight);
+
+ if (msgs.length < 50) hasMore = false;
+ loadingMore = false;
+}
+
+function appendMessage(msg) {
+ if (!messageList) return;
+ const wasAtBottom = messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight < 40;
+ messageList.appendChild(createMessageEl(msg));
+ if (!oldestTimestamp || msg.createdAt < oldestTimestamp) {
+ oldestTimestamp = msg.createdAt;
+ }
+ if (wasAtBottom) scrollToBottom();
+}
+
+function handleSend() {
+ if (!inputArea) return;
+ const text = inputArea.value;
+ if (chatClient.sendMessage(text)) {
+ inputArea.value = "";
+ inputArea.style.height = "auto";
+ }
+}
+
+function createWidget() {
+ bubble = document.createElement("div");
+ bubble.id = "chat-bubble";
+ bubble.className = "fixed bottom-5 right-5 z-[9998] cursor-pointer select-none";
+ bubble.innerHTML = `
+
+
+
+
+ `;
+ bubble.addEventListener("click", togglePanel);
+
+ panel = document.createElement("div");
+ panel.id = "chat-panel";
+ panel.className = "hidden fixed bottom-[4.5rem] right-5 z-[9998] w-[360px] max-h-[500px] flex flex-col rounded-xl border border-zinc-700/70 bg-zinc-900/95 backdrop-blur-md shadow-2xl shadow-black/40 overflow-hidden";
+ panel.innerHTML = `
+
+
+
+ Team Chat
+
+
+
+
+
+
All notifications
+
Mute
+
Mute + Collapse
+
Hide
+
+
+
+
+
+
+
+
+ Read-only — you can view but not send messages
+
+ `;
+
+ document.body.appendChild(bubble);
+ document.body.appendChild(panel);
+
+ messageList = panel.querySelector("#chat-messages");
+ inputArea = panel.querySelector("#chat-input");
+ sendBtn = panel.querySelector("#chat-send-btn");
+ muteDropdown = panel.querySelector("#chat-mute-dropdown");
+ muteDropdownMenu = panel.querySelector("#chat-mute-menu");
+ muteDropdownLabel = panel.querySelector("#chat-mute-label");
+
+ const closeBtn = panel.querySelector("#chat-close-btn");
+ const inputContainer = panel.querySelector("#chat-input-area");
+ const readonlyBar = panel.querySelector("#chat-readonly-bar");
+
+ const MUTE_LABELS = {
+ none: "All notifications",
+ mute: "Mute",
+ mute_collapse: "Mute + Collapse",
+ hide: "Hide",
+ };
+
+ muteDropdownLabel.textContent = MUTE_LABELS[chatClient.getMuteMode()] || MUTE_LABELS.none;
+
+ panel.querySelector("#chat-mute-btn").addEventListener("click", (e) => {
+ e.stopPropagation();
+ muteDropdownMenu.classList.toggle("hidden");
+ });
+
+ muteDropdownMenu.querySelectorAll(".chat-mute-option").forEach((opt) => {
+ opt.addEventListener("click", () => {
+ const value = opt.dataset.value;
+ chatClient.setMuteMode(value);
+ muteDropdownLabel.textContent = MUTE_LABELS[value] || MUTE_LABELS.none;
+ muteDropdownMenu.classList.add("hidden");
+ updateMuteVisibility();
+ });
+ });
+
+ document.addEventListener("click", () => {
+ if (muteDropdownMenu && !muteDropdownMenu.classList.contains("hidden")) {
+ muteDropdownMenu.classList.add("hidden");
+ }
+ });
+
+ closeBtn.addEventListener("click", () => {
+ if (panelOpen) togglePanel();
+ });
+
+ sendBtn.addEventListener("click", handleSend);
+
+ inputArea.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ });
+
+ inputArea.addEventListener("input", () => {
+ inputArea.style.height = "auto";
+ inputArea.style.height = Math.min(inputArea.scrollHeight, 100) + "px";
+ });
+
+ messageList.addEventListener("scroll", () => {
+ if (messageList.scrollTop < 40 && hasMore && !loadingMore) {
+ loadMoreHistory();
+ }
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && panelOpen) {
+ togglePanel();
+ }
+ });
+
+ document.addEventListener("mousedown", (e) => {
+ if (panelOpen && !panel.contains(e.target) && !bubble.contains(e.target)) {
+ togglePanel();
+ }
+ });
+
+ chatClient.onReady((history, writable) => {
+ messageList.innerHTML = "";
+ oldestTimestamp = null;
+ hasMore = true;
+
+ for (const msg of history) {
+ messageList.appendChild(createMessageEl(msg));
+ if (!oldestTimestamp || msg.createdAt < oldestTimestamp) {
+ oldestTimestamp = msg.createdAt;
+ }
+ }
+ scrollToBottom();
+
+ if (writable) {
+ inputContainer.classList.remove("hidden");
+ readonlyBar.classList.add("hidden");
+ } else {
+ inputContainer.classList.add("hidden");
+ readonlyBar.classList.remove("hidden");
+ }
+ });
+
+ chatClient.onMessage((msg) => {
+ appendMessage(msg);
+ const mute = chatClient.getMuteMode();
+ if (panelOpen) {
+ chatClient.resetUnread();
+ updateBubbleBadge();
+ }
+ if (mute === "mute_collapse" && panelOpen) {
+ togglePanel();
+ }
+ });
+
+ chatClient.onUnreadChanged(() => {
+ updateBubbleBadge();
+ });
+
+ updateBubbleBadge();
+ updateMuteVisibility();
+
+ chatClient.start();
+}
+
+export function init() {
+ if (initialized) return;
+ initialized = true;
+ createWidget();
+}
+
+export function show() {
+ if (chatClient.getMuteMode() === "hide") {
+ chatClient.setMuteMode("none");
+ }
+ if (bubble) bubble.classList.remove("hidden");
+ updateMuteVisibility();
+}
+
+export function isHidden() {
+ return chatClient.getMuteMode() === "hide";
+}
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
index 351c508..af44444 100644
--- a/Overlord-Server/public/assets/main.js
+++ b/Overlord-Server/public/assets/main.js
@@ -39,6 +39,7 @@ const bulkScreenshotBtn = document.getElementById("bulk-screenshot");
const bulkDisconnectBtn = document.getElementById("bulk-disconnect");
const bulkUninstallBtn = document.getElementById("bulk-uninstall");
const bulkClearBtn = document.getElementById("bulk-clear");
+const bulkGroupBtn = document.getElementById("bulk-group");
const serverVersionText = document.getElementById("server-version-text");
const selectedClients = new Set();
let lastNonOnlineStatus = "all";
@@ -785,6 +786,115 @@ bulkUninstallBtn?.addEventListener("click", async () => {
setTimeout(() => loadWithOptions({ force: true }), 1000);
});
+bulkGroupBtn?.addEventListener("click", () => {
+ if (selectedClients.size === 0) return;
+ openBulkGroupPicker([...selectedClients]);
+});
+
+async function openBulkGroupPicker(clientIds) {
+ const groups = await loadGroups();
+
+ const overlay = document.createElement("div");
+ overlay.className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60";
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
+
+ const modal = document.createElement("div");
+ modal.className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-6 w-[400px] max-h-[80vh] flex flex-col gap-4";
+ modal.innerHTML = `
+ Set Group for ${clientIds.length} client(s)
+
+
+ `;
+
+ const listEl = modal.querySelector(".bulk-group-list");
+
+ async function applyGroup(groupId) {
+ overlay.remove();
+ try {
+ const res = await fetch("/api/clients/bulk-group", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ clientIds, groupId }),
+ });
+ if (!res.ok) {
+ const d = await res.json().catch(() => ({}));
+ alert(d.error || "Failed to set group");
+ return;
+ }
+ } catch (err) {
+ console.error(err);
+ alert("Failed to set group");
+ return;
+ }
+ selectedClients.clear();
+ document.querySelectorAll(".client-checkbox").forEach((cb) => (cb.checked = false));
+ updateBulkToolbar();
+ refreshGroupFilter();
+ setTimeout(() => loadWithOptions({ force: true }), 200);
+ }
+
+ function renderList() {
+ listEl.innerHTML = "";
+ const noneBtn = document.createElement("button");
+ noneBtn.className = "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors text-slate-300 hover:bg-slate-800";
+ noneBtn.innerHTML = ' No Group';
+ noneBtn.addEventListener("click", () => applyGroup(null));
+ listEl.appendChild(noneBtn);
+
+ groups.forEach((g) => {
+ const btn = document.createElement("button");
+ btn.className = "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors text-slate-300 hover:bg-slate-800";
+ btn.innerHTML = ` ${escapeHtml(g.name)}`;
+ btn.addEventListener("click", () => applyGroup(g.id));
+ listEl.appendChild(btn);
+ });
+ }
+
+ renderList();
+
+ const createBtn = modal.querySelector(".group-create-btn");
+ createBtn.addEventListener("click", async () => {
+ const nameInput = modal.querySelector(".group-new-name");
+ const colorInput = modal.querySelector(".group-new-color");
+ const name = nameInput.value.trim();
+ const color = colorInput.value;
+ if (!name) { nameInput.focus(); return; }
+ try {
+ const res = await fetch("/api/groups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, color }),
+ });
+ if (!res.ok) {
+ const d = await res.json().catch(() => ({}));
+ alert(d.error || "Failed to create group");
+ return;
+ }
+ const newGroup = await res.json();
+ groups.push(newGroup);
+ nameInput.value = "";
+ renderList();
+ refreshGroupFilter();
+ } catch (err) {
+ console.error(err);
+ alert("Failed to create group");
+ }
+ });
+
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+ modal.querySelector(".group-new-name").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") createBtn.click();
+ });
+}
+
window.toggleClientSelection = toggleClientSelection;
window.isClientSelected = (clientId) => selectedClients.has(clientId);
window.syncClientSelection = syncSelectionState;
diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js
index ecce22d..4462512 100644
--- a/Overlord-Server/public/assets/nav.js
+++ b/Overlord-Server/public/assets/nav.js
@@ -10,6 +10,7 @@ import { mountNav } from "./nav/template.js";
import { createAdaptiveNavController } from "./nav/layout.js";
import { applyUserRoleUI } from "./nav/role-ui.js";
import { showCertBannerIfNeeded } from "./cert-banner.js";
+import * as chatWidget from "./chat-widget.js";
const host = document.getElementById("top-nav");
if (host) {
@@ -137,4 +138,19 @@ if (host) {
if (refs.usernameDisplay && refs.roleBadge) {
loadCurrentUser();
}
+
+ chatWidget.init();
+
+ if (chatWidget.isHidden() && refs.navUtility) {
+ const restoreBtn = document.createElement("button");
+ restoreBtn.id = "chat-restore-btn";
+ restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors";
+ restoreBtn.title = "Show team chat";
+ restoreBtn.innerHTML = 'Chat';
+ restoreBtn.addEventListener("click", () => {
+ chatWidget.show();
+ restoreBtn.remove();
+ });
+ refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
+ }
}
diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js
index ea28330..2507342 100644
--- a/Overlord-Server/public/assets/settings.js
+++ b/Overlord-Server/public/assets/settings.js
@@ -694,6 +694,53 @@ async function saveAppearanceSettings(event) {
showMessage("Custom CSS saved. Reload the page to apply the new styles.");
}
+const chatSettingsSection = document.getElementById("chat-settings-section");
+const chatSettingsForm = document.getElementById("chat-settings-form");
+const chatRetentionDaysInput = document.getElementById("chat-retention-days");
+
+async function loadChatSettings() {
+ if (!isAdmin(currentUser?.role)) return;
+ if (chatSettingsSection) chatSettingsSection.classList.remove("hidden");
+ try {
+ const res = await fetch("/api/settings/chat", { credentials: "include" });
+ const data = await res.json().catch(() => ({}));
+ if (res.ok && data.chat) {
+ if (chatRetentionDaysInput) chatRetentionDaysInput.value = data.chat.retentionDays ?? 30;
+ }
+ } catch {
+ console.warn("Failed to load chat settings");
+ }
+}
+
+async function saveChatSettings(event) {
+ event.preventDefault();
+ if (!isAdmin(currentUser?.role)) {
+ showMessage("Admin role required.", "error");
+ return;
+ }
+
+ const retentionDays = Number(chatRetentionDaysInput?.value);
+ if (!Number.isFinite(retentionDays) || retentionDays < 0 || retentionDays > 365) {
+ showMessage("Retention must be 0-365 days.", "error");
+ return;
+ }
+
+ const res = await fetch("/api/settings/chat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ retentionDays }),
+ });
+
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ showMessage(data.error || "Failed to save chat settings.", "error");
+ return;
+ }
+
+ showMessage("Chat settings saved.");
+}
+
function showExportImportMessage(text, type = "ok") {
if (!exportImportMessage) return;
exportImportMessage.textContent = text;
@@ -1015,6 +1062,7 @@ async function init() {
await loadSecurityPolicy();
await loadTlsSettings();
await loadAppearanceSettings();
+ await loadChatSettings();
await loadBannedIps();
passwordForm.addEventListener("submit", updatePassword);
@@ -1039,6 +1087,7 @@ async function init() {
tlsForm.addEventListener("submit", saveTlsSettings);
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
+ if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings);
if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings);
refreshBansBtn.addEventListener("click", loadBannedIps);
diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html
index b731efd..7936af3 100644
--- a/Overlord-Server/public/index.html
+++ b/Overlord-Server/public/index.html
@@ -154,6 +154,12 @@
>
Uninstall
+