add chat system

This commit is contained in:
KingKDot
2026-04-14 21:56:18 -05:00
parent 6f14bf35e2
commit eb6ec86773
20 changed files with 1211 additions and 5 deletions
@@ -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();
}
@@ -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 = `
<div class="relative flex items-center justify-center w-12 h-12 rounded-full bg-sky-600 hover:bg-sky-500 shadow-lg shadow-sky-900/30 transition-colors">
<i class="fa-solid fa-comments text-white text-lg"></i>
<span data-chat-badge class="hidden absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full bg-rose-500 text-white text-xs flex items-center justify-center font-semibold"></span>
</div>
`;
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 = `
<div class="flex items-center justify-between px-4 py-2.5 border-b border-zinc-700/60 bg-zinc-800/60 shrink-0">
<div class="flex items-center gap-2">
<i class="fa-solid fa-comments text-sky-400 text-sm"></i>
<span class="font-semibold text-slate-200 text-sm">Team Chat</span>
</div>
<div class="flex items-center gap-2">
<div id="chat-mute-dropdown" class="relative">
<button id="chat-mute-btn" class="flex items-center gap-1.5 bg-zinc-700/70 border border-zinc-600 text-slate-300 text-xs rounded px-2 py-1 outline-none cursor-pointer hover:bg-zinc-600/70 transition-colors" title="Notification mode">
<span id="chat-mute-label">All notifications</span>
<i class="fa-solid fa-chevron-down text-[10px] text-slate-400"></i>
</button>
<div id="chat-mute-menu" class="hidden absolute right-0 top-full mt-1 w-44 bg-zinc-800 border border-zinc-600 rounded-lg shadow-xl shadow-black/50 py-1 z-[9999]">
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="none">All notifications</div>
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="mute">Mute</div>
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="mute_collapse">Mute + Collapse</div>
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="hide">Hide</div>
</div>
</div>
<button id="chat-close-btn" class="text-slate-400 hover:text-slate-200 transition-colors" title="Close">
<i class="fa-solid fa-xmark text-sm"></i>
</button>
</div>
</div>
<div id="chat-messages" class="flex-1 overflow-y-auto min-h-0 py-1" style="max-height: 370px;"></div>
<div id="chat-input-area" class="hidden shrink-0 border-t border-zinc-700/60 bg-zinc-800/40 p-2">
<div class="flex items-end gap-2">
<textarea id="chat-input" rows="1" maxlength="2000"
class="flex-1 bg-zinc-800 border border-zinc-600 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none resize-none leading-snug"
placeholder="Type a message..." style="max-height: 100px;"></textarea>
<button id="chat-send-btn" class="px-3 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors shrink-0" title="Send">
<i class="fa-solid fa-paper-plane"></i>
</button>
</div>
</div>
<div id="chat-readonly-bar" class="hidden shrink-0 border-t border-zinc-700/60 bg-zinc-800/40 px-3 py-2">
<span class="text-xs text-slate-500 italic">Read-only &mdash; you can view but not send messages</span>
</div>
`;
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";
}
+110
View File
@@ -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 = `
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-layer-group text-blue-400"></i> Set Group for ${clientIds.length} client(s)</h3>
<div class="bulk-group-list flex flex-col gap-1 overflow-y-auto max-h-60"></div>
<div class="border-t border-slate-700 pt-3">
<p class="text-xs text-slate-400 mb-2">Create new group</p>
<div class="flex gap-2">
<input type="text" class="group-new-name flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 outline-none focus:border-blue-500" placeholder="Group name" maxlength="64" />
<input type="color" class="group-new-color w-10 h-10 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer" value="#3b82f6" />
<button class="group-create-btn px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium">Create</button>
</div>
</div>
`;
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 = '<i class="fa-solid fa-xmark text-slate-500"></i> 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 = `<span class="inline-block w-3 h-3 rounded-full flex-shrink-0" style="background:${g.color}"></span> ${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;
+16
View File
@@ -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 = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
restoreBtn.addEventListener("click", () => {
chatWidget.show();
restoreBtn.remove();
});
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
}
}
+49
View File
@@ -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);
+6
View File
@@ -154,6 +154,12 @@
>
<i class="fa-solid fa-trash"></i> Uninstall
</button>
<button
id="bulk-group"
class="px-3 py-2 rounded-lg bg-blue-800/60 hover:bg-blue-700 text-blue-100 border border-blue-600 transition-colors"
>
<i class="fa-solid fa-layer-group"></i> Set Group
</button>
<button
id="bulk-clear"
class="px-3 py-2 rounded-lg bg-slate-800/60 hover:bg-slate-700 text-slate-300 border border-slate-600 transition-colors"
+36
View File
@@ -586,6 +586,42 @@
</form>
</section>
<!-- Team Chat -->
<section id="chat-settings-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-comments text-sky-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Team Chat</h2>
<p class="text-sm text-slate-400">Admin-only. Configure message retention for the team chat.</p>
</div>
</div>
<form id="chat-settings-form" class="space-y-4">
<label class="block">
<span class="text-sm text-slate-300 font-medium">Message Retention (days)</span>
<input
type="number"
id="chat-retention-days"
min="0"
max="365"
step="1"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500 text-sm"
/>
<p class="text-xs text-slate-500 mt-1">Messages older than this will be automatically deleted. Set to 0 to keep messages forever. Max 365 days.</p>
</label>
<button
type="submit"
id="chat-settings-save-btn"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-700 hover:bg-sky-600 transition-colors text-white text-sm font-medium"
>
<i class="fa-solid fa-floppy-disk"></i>Save Chat Settings
</button>
</form>
</section>
<!-- Custom CSS -->
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">