/** * chat.js – SexyChat Frontend Logic (Phase 2) * * Features: * - Socket.io with JWT reconnect * - AES-GCM Encryption (via crypto.js) * - PM Persistence & History * - Violet AI (Transit-encrypted) * - Trial/Paywall management */ "use strict"; const socket = io({ autoConnect: false, auth: { token: localStorage.getItem("sexychat_token") } }); const state = { username: null, isAdmin: false, role: "user", isRegistered: false, hasAiAccess: false, aiMessagesUsed: 0, currentRoom: "lobby", pms: {}, // room -> { username, key, messages: [] } nicklist: [], ignoredUsers: new Set(), cryptoKey: null, // derived from password isSidebarOpen: window.innerWidth > 768, authMode: "guest" }; const AI_BOT_NAME = "Violet"; const AI_FREE_LIMIT = 3; // ── Selectors ───────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); const joinScreen = $("join-screen"); const chatScreen = $("chat-screen"); const joinForm = $("join-form"); const usernameInput = $("username-input"); const passwordInput = $("password-input"); const emailInput = $("email-input"); const modPassword = $("mod-password-input"); const joinBtn = $("join-btn"); const joinError = $("join-error"); const authTabs = document.querySelectorAll(".auth-tab"); const sidebar = $("nicklist-sidebar"); const nicklist = $("nicklist"); const tabBar = $("tab-bar"); const panels = $("panels"); const messageForm = $("message-form"); const messageInput = $("message-input"); const logoutBtn = $("logout-btn"); const pmModal = $("pm-modal"); const paywallModal = $("paywall-modal"); const contextMenu = $("context-menu"); const trialBadge = $("violet-trial-badge"); const violetTyping = $("violet-typing"); const settingsModal = $("settings-modal"); // ── Auth & Init ─────────────────────────────────────────────────────────── // Toggle Auth Modes authTabs.forEach(tab => { tab.addEventListener("click", () => { authTabs.forEach(t => t.classList.remove("active")); tab.classList.add("active"); state.authMode = tab.dataset.mode; // UI visibility based on mode const isAuth = state.authMode !== "guest"; const isReg = state.authMode === "register"; document.querySelectorAll(".auth-only").forEach(el => el.classList.toggle("hidden", !isAuth)); document.querySelectorAll(".register-only").forEach(el => el.classList.toggle("hidden", !isReg)); usernameInput.placeholder = isAuth ? "Username" : "Choose your nickname"; passwordInput.required = isAuth; }); }); // Join the Room joinForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = usernameInput.value.trim(); const password = passwordInput.value.trim(); const email = emailInput.value.trim(); const modPw = modPassword.value.trim(); if (!username) return; if (state.authMode !== "guest" && !password) return; joinBtn.disabled = true; joinBtn.innerText = "Connecting..."; // Derive Encryption Key if password provided if (password) { try { state.cryptoKey = await SexyChato.deriveKey(password, username); } catch (err) { console.error("Key derivation failed", err); } } socket.connect(); socket.emit("join", { mode: state.authMode, username, password, email, mod_password: modPw }); }); // Handle Token Restore on Load window.addEventListener("DOMContentLoaded", () => { // ── Restore Theme from localStorage ──────────────────────────────────── const savedTheme = localStorage.getItem("sexychat_theme") || "midnight-purple"; document.documentElement.setAttribute("data-theme", savedTheme); // Update active theme button if it exists const themeButtons = document.querySelectorAll("[data-theme-button]"); themeButtons.forEach(btn => { btn.classList.toggle("active", btn.dataset.themeButton === savedTheme); }); const token = localStorage.getItem("sexychat_token"); if (token) { // Auto-restore session from stored JWT joinBtn.disabled = true; joinBtn.innerText = "Restoring session..."; socket.connect(); socket.emit("join", { mode: "restore" }); // If restore fails, reset the form so user can log in manually const restoreTimeout = setTimeout(() => { joinBtn.disabled = false; joinBtn.innerText = "Enter the Room"; }, 5000); const origJoined = socket.listeners("joined"); socket.once("joined", () => clearTimeout(restoreTimeout)); socket.once("error", () => { clearTimeout(restoreTimeout); joinBtn.disabled = false; joinBtn.innerText = "Enter the Room"; }); } }); // ── Socket Events ────────────────────────────────────────────────────────── socket.on("joined", (data) => { state.username = data.username; state.isAdmin = data.is_admin; state.role = data.role || "user"; state.isRegistered = data.is_registered; state.hasAiAccess = data.has_ai_access; state.aiMessagesUsed = data.ai_messages_used; state.email = data.email || null; if (data.token) localStorage.setItem("sexychat_token", data.token); if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list); $("my-username-badge").innerText = state.username; joinScreen.classList.add("hidden"); chatScreen.classList.remove("hidden"); updateVioletBadge(); // Show admin panel button for mods+ const adminBtn = $("admin-btn"); if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin); }); socket.on("error", (data) => { joinError.innerText = data.msg; joinBtn.disabled = false; joinBtn.innerText = "Enter the Room"; if (data.msg.includes("Session expired")) { localStorage.removeItem("sexychat_token"); } }); socket.on("nicklist", (data) => { state.nicklist = data.users; renderNicklist(); $("user-count-badge").innerText = `${data.users.length} online`; }); socket.on("system", (data) => { addMessage("lobby", { system: true, text: data.msg, ts: data.ts }); }); socket.on("message", (data) => { if (state.ignoredUsers.has(data.username)) return; addMessage("lobby", { sender: data.username, text: data.text, ts: data.ts, isAdmin: data.is_admin, isRegistered: data.is_registered, sent: data.username === state.username }); }); // ── Private Messaging ───────────────────────────────────────────────────── socket.on("pm_invite", async (data) => { if (state.pms[data.room]) return; // Already accepted // Store the room key for later use if (data.room_key) { state._pendingRoomKeys = state._pendingRoomKeys || {}; state._pendingRoomKeys[data.room] = data.room_key; } $("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`; pmModal.classList.remove("hidden"); $("pm-accept-btn").onclick = () => { socket.emit("pm_accept", { room: data.room }); openPMTab(data.from, data.room, data.room_key); pmModal.classList.add("hidden"); }; $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); }); socket.on("pm_ready", (data) => { openPMTab(data.with, data.room, data.room_key); }); async function openPMTab(otherUser, room, roomKeyB64) { if (state.pms[room]) { switchTab(room); return; } state.pms[room] = { username: otherUser, messages: [], sharedKey: null }; // Import the server-provided room key for user-to-user PMs const isViolet = otherUser.toLowerCase() === "violet"; if (!isViolet && roomKeyB64) { try { state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64); } catch (err) { console.error("Failed to import room key", err); } } // Create Tab let title = `👤 ${otherUser}`; if (otherUser.toLowerCase() === "violet") title = `🤖 Violet`; const tab = document.createElement("button"); tab.className = "tab-btn"; tab.dataset.room = room; tab.id = `tab-${room}`; tab.innerHTML = `${title}`; tab.onclick = () => switchTab(room); tabBar.appendChild(tab); // Create Panel const panel = document.createElement("div"); panel.className = "panel"; panel.id = `panel-${room}`; panel.dataset.room = room; panel.innerHTML = `
${otherUser.toLowerCase() === 'violet' ? `` : ''} `; panels.appendChild(panel); switchTab(room); // Load History if registered if (state.isRegistered && state.cryptoKey) { try { const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, { headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` } }); const histData = await resp.json(); // Use room_key from history response if we don't have one yet if (!isViolet && histData.room_key && !state.pms[room].sharedKey) { try { state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key); } catch (err) { console.error("Failed to import history room key", err); } } // Pick the right decryption key: room key for users, personal key for Violet const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey); if (histData.messages) { for (const m of histData.messages) { try { const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce); addMessage(room, { sender: m.from_me ? state.username : otherUser, text: plain, ts: m.ts, sent: m.from_me }); } catch (err) { addMessage(room, { sender: m.from_me ? state.username : otherUser, text: "[Could not decrypt message]", ts: m.ts, sent: m.from_me }); } } } } catch (err) { console.error("Failed to load PM history", err); } } } socket.on("pm_message", async (data) => { if (state.ignoredUsers.has(data.from)) return; let text = data.text; if (data.ciphertext) { // Pick the right key: room shared key for user-to-user, personal key for Violet const pm = state.pms[data.room]; const decryptKey = pm?.sharedKey || state.cryptoKey; if (decryptKey) { try { text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce); } catch (err) { text = "[Encrypted Message - Could not decrypt]"; } } else { text = "[Encrypted Message - No key available]"; } } addMessage(data.room, { sender: data.from, text: text, ts: data.ts, sent: data.from === state.username }); if (state.currentRoom !== data.room) { const tab = $(`tab-${data.room}`); if (tab) tab.classList.add("unread"); } }); // ── Violet AI Logic ─────────────────────────────────────────────────────── socket.on("violet_typing", (data) => { const room = data.room || "lobby"; const indicator = $(`typing-${room}`); if (indicator) { indicator.classList.toggle("hidden", !data.busy); } }); socket.on("ai_response", async (data) => { if (data.error === "ai_limit_reached") { paywallModal.classList.remove("hidden"); return; } state.aiMessagesUsed = data.ai_messages_used; state.hasAiAccess = data.has_ai_access; updateVioletBadge(); const room = data.room || "ai-violet"; let text = data.text || "[Decryption Error]"; if (data.ciphertext && state.cryptoKey) { text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); } addMessage(room, { sender: AI_BOT_NAME, text: text, ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), sent: false }); }); socket.on("ai_unlock", (data) => { state.hasAiAccess = data.has_ai_access !== undefined ? data.has_ai_access : true; updateVioletBadge(); paywallModal.classList.add("hidden"); if (data.msg) addMessage("ai-violet", { system: true, text: data.msg }); }); socket.on("ignore_status", (data) => { if (data.ignored) state.ignoredUsers.add(data.target); else state.ignoredUsers.delete(data.target); renderNicklist(); }); function updateVioletBadge() { if (!trialBadge) return; if (state.hasAiAccess) { trialBadge.classList.add("hidden"); } else { const left = AI_FREE_LIMIT - state.aiMessagesUsed; trialBadge.innerText = Math.max(0, left); trialBadge.classList.toggle("hidden", left <= 0 && state.aiMessagesUsed < AI_FREE_LIMIT); if (left <= 0) { trialBadge.innerText = "!"; // Paywall indicator } } } // ── UI Actions ──────────────────────────────────────────────────────────── messageForm.addEventListener("submit", async (e) => { e.preventDefault(); const text = messageInput.value.trim(); if (!text) return; if (state.currentRoom === "lobby") { socket.emit("message", { text }); } else if (state.currentRoom.startsWith("pm:")) { const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet"); // /reset command in Violet PM clears conversation memory if (isVioletRoom && text.toLowerCase() === "/reset") { socket.emit("violet_reset"); messageInput.value = ""; messageInput.style.height = "auto"; return; } if (isVioletRoom) { if (state.isRegistered && state.cryptoKey) { // AI Transit Encryption PM Flow const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey); const encrypted = await SexyChato.encrypt(state.cryptoKey, text); socket.emit("pm_message", { room: state.currentRoom, ciphertext: encrypted.ciphertext, nonce: encrypted.nonce, transit_key: transitKeyB64 }); } else { // Guest/admin plaintext fallback socket.emit("pm_message", { room: state.currentRoom, text }); } } else if (state.isRegistered && state.cryptoKey) { // User-to-user encrypted PM: use the shared room key if available const pm = state.pms[state.currentRoom]; const encryptKey = pm?.sharedKey || state.cryptoKey; const encrypted = await SexyChato.encrypt(encryptKey, text); socket.emit("pm_message", { room: state.currentRoom, ciphertext: encrypted.ciphertext, nonce: encrypted.nonce }); } else { // Guest PM (Plaintext) socket.emit("pm_message", { room: state.currentRoom, text }); } } messageInput.value = ""; messageInput.style.height = "auto"; }); // Enter to send, Shift+Enter for newline messageInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && prefs.enterToSend) { e.preventDefault(); messageForm.requestSubmit(); } }); // Auto-expand textarea messageInput.addEventListener("input", () => { messageInput.style.height = "auto"; messageInput.style.height = (messageInput.scrollHeight) + "px"; }); function switchTab(room) { state.currentRoom = room; document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".panel").forEach(p => p.classList.remove("active")); const tab = $(`tab-${room}`); if (tab) { tab.classList.add("active"); tab.classList.remove("unread"); } $(`panel-${room}`).classList.add("active"); // Scroll to bottom const box = $(`messages-${room}`); if (box) box.scrollTop = box.scrollHeight; } function formatTs(ts) { if (!ts || prefs.timeFormat === "24h") return ts || ""; // Convert HH:MM (24h) to 12h const parts = (ts || "").match(/^(\d{1,2}):(\d{2})$/); if (!parts) return ts; let h = parseInt(parts[1], 10); const m = parts[2]; const ampm = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; return `${h}:${m} ${ampm}`; } function addMessage(room, msg) { const list = $(`messages-${room}`); if (!list) return; const div = document.createElement("div"); if (msg.system) { div.className = "msg msg-system"; div.innerHTML = msg.text.replace(/\*\*(.*?)\*\*/g, "$1"); } else { div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`; div.innerHTML = `
${formatTs(msg.ts)} ${msg.sender}
${escapeHTML(msg.text)}
`; } list.appendChild(div); list.scrollTop = list.scrollHeight; } function renderNicklist() { nicklist.innerHTML = ""; state.nicklist.forEach(u => { const li = document.createElement("li"); const isIgnored = state.ignoredUsers.has(u.username); const isUnverified = u.is_registered && !u.is_verified; const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || ""; li.innerHTML = ` ${roleIcon ? `${roleIcon} ` : ''} ${u.username} ${u.is_registered ? '' : ''} ${isIgnored ? ' (ignored)' : ''} ${isUnverified ? ' (unverified)' : ''} `; li.oncontextmenu = (e) => showContextMenu(e, u); li.onclick = () => { if (u.username !== state.username) socket.emit("pm_open", { target: u.username }); }; nicklist.appendChild(li); }); } function showContextMenu(e, user) { e.preventDefault(); if (user.username === state.username) return; // Position menu contextMenu.style.left = `${e.pageX}px`; contextMenu.style.top = `${e.pageY}px`; contextMenu.classList.remove("hidden"); // Configure items const pmItem = contextMenu.querySelector('[data-action="pm"]'); const ignoreItem = contextMenu.querySelector('[data-action="ignore"]'); const unignoreItem = contextMenu.querySelector('[data-action="unignore"]'); const verifyItem = contextMenu.querySelector('[data-action="verify"]'); const modItems = contextMenu.querySelectorAll(".mod-item"); const isIgnored = state.ignoredUsers.has(user.username); const isUnverified = user.is_registered && !user.is_verified; ignoreItem.classList.toggle("hidden", !state.isRegistered || isIgnored); unignoreItem.classList.toggle("hidden", !state.isRegistered || !isIgnored); // Verify option only for admins if target is unverified const showVerify = state.isAdmin && isUnverified; if (verifyItem) verifyItem.classList.toggle("hidden", !showVerify); modItems.forEach(el => { if (el.dataset.action !== "verify") { el.classList.toggle("hidden", !state.isAdmin); } }); // Store target for click handler (uses event delegation below) contextMenu._targetUser = user.username; // Remove old inline onclick handlers and re-bind contextMenu.querySelectorAll(".menu-item").forEach(item => { item.onclick = () => { const action = item.dataset.action; executeMenuAction(action, contextMenu._targetUser); contextMenu.classList.add("hidden"); }; }); } function executeMenuAction(action, target) { switch(action) { case "pm": socket.emit("pm_open", { target }); break; case "ignore": socket.emit("user_ignore", { target }); break; case "unignore": socket.emit("user_unignore", { target }); break; case "kick": socket.emit("mod_kick", { target }); break; case "mute": socket.emit("mod_mute", { target }); break; case "ban": socket.emit("mod_ban", { target }); break; case "kickban": socket.emit("mod_kickban", { target }); break; case "verify": socket.emit("mod_verify", { target }); break; } } // Global click to hide context menu window.addEventListener("click", (e) => { const menu = $("context-menu"); if (menu && !menu.contains(e.target)) { menu.classList.add("hidden"); } }); // ── Admin Tools ─────────────────────────────────────────────────────────── window.modKick = (u) => socket.emit("mod_kick", { target: u }); window.modBan = (u) => socket.emit("mod_ban", { target: u }); window.modMute = (u) => socket.emit("mod_mute", { target: u }); // ── Modals & Misc ────────────────────────────────────────────────────────── $("sidebar-toggle").onclick = () => { sidebar.classList.toggle("open"); }; // tab-ai-violet is created dynamically when user opens Violet PM $("tab-lobby").onclick = () => switchTab("lobby"); $("close-paywall").onclick = () => paywallModal.classList.add("hidden"); $("unlock-btn").onclick = async () => { // In production, this redirects to a real payment gateway (Stripe Checkout). // The server-side webhook will unlock AI access after payment confirmation. // For now, show a placeholder message. alert("Payment integration coming soon. Contact the administrator to unlock Violet."); }; logoutBtn.onclick = () => { localStorage.removeItem("sexychat_token"); location.reload(); }; // ── Settings Panel ───────────────────────────────────────────────────────── // Load saved preferences from localStorage const prefs = { fontSize: parseInt(localStorage.getItem("sc_fontsize") || "14"), timeFormat: localStorage.getItem("sc_timeformat") || "12h", enterToSend: localStorage.getItem("sc_enter_send") !== "false", sounds: localStorage.getItem("sc_sounds") !== "false", theme: localStorage.getItem("sc_theme") || "midnight-purple", }; // Apply saved font size on load document.documentElement.style.setProperty("--chat-font-size", prefs.fontSize + "px"); // Apply saved theme on load document.documentElement.setAttribute("data-theme", prefs.theme); $("settings-btn").onclick = () => { // Populate current values $("settings-username").textContent = state.username || "—"; $("settings-email").textContent = state.email || "Not set"; $("settings-ai-status").textContent = state.hasAiAccess ? "✓ Unlimited" : `Free (${AI_FREE_LIMIT - state.aiMessagesUsed} left)`; $("settings-ai-used").textContent = state.aiMessagesUsed.toString(); // Show password change only for registered users document.querySelectorAll(".registered-only").forEach(el => el.classList.toggle("hidden", !state.isRegistered) ); // Sync toggle states $("settings-fontsize").value = prefs.fontSize; $("settings-fontsize-val").textContent = prefs.fontSize + "px"; document.querySelectorAll("[data-tf]").forEach(b => { b.classList.toggle("active", b.dataset.tf === prefs.timeFormat); }); $("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off"; $("settings-enter-send").classList.toggle("active", prefs.enterToSend); $("settings-sounds").textContent = prefs.sounds ? "On" : "Off"; $("settings-sounds").classList.toggle("active", prefs.sounds); // Sync theme picker document.querySelectorAll(".theme-swatch").forEach(s => { s.classList.toggle("active", s.dataset.theme === prefs.theme); }); settingsModal.classList.remove("hidden"); }; $("close-settings").onclick = () => settingsModal.classList.add("hidden"); settingsModal.addEventListener("click", (e) => { if (e.target === settingsModal) settingsModal.classList.add("hidden"); }); // Tab switching document.querySelectorAll(".settings-tab").forEach(tab => { tab.addEventListener("click", () => { document.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".settings-pane").forEach(p => p.classList.remove("active")); tab.classList.add("active"); $("stab-" + tab.dataset.stab).classList.add("active"); }); }); // Font size slider $("settings-fontsize").addEventListener("input", (e) => { const val = parseInt(e.target.value); prefs.fontSize = val; localStorage.setItem("sc_fontsize", val); $("settings-fontsize-val").textContent = val + "px"; document.documentElement.style.setProperty("--chat-font-size", val + "px"); }); // Theme picker document.querySelectorAll(".theme-swatch").forEach(swatch => { swatch.addEventListener("click", () => { const theme = swatch.dataset.theme; prefs.theme = theme; localStorage.setItem("sc_theme", theme); document.documentElement.setAttribute("data-theme", theme); document.querySelectorAll(".theme-swatch").forEach(s => s.classList.remove("active")); swatch.classList.add("active"); }); }); // Timestamp format toggle document.querySelectorAll("[data-tf]").forEach(btn => { btn.addEventListener("click", () => { document.querySelectorAll("[data-tf]").forEach(b => b.classList.remove("active")); btn.classList.add("active"); prefs.timeFormat = btn.dataset.tf; localStorage.setItem("sc_timeformat", btn.dataset.tf); }); }); // Enter-to-send toggle $("settings-enter-send").addEventListener("click", () => { prefs.enterToSend = !prefs.enterToSend; localStorage.setItem("sc_enter_send", prefs.enterToSend); $("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off"; $("settings-enter-send").classList.toggle("active", prefs.enterToSend); }); // Sounds toggle $("settings-sounds").addEventListener("click", () => { prefs.sounds = !prefs.sounds; localStorage.setItem("sc_sounds", prefs.sounds); $("settings-sounds").textContent = prefs.sounds ? "On" : "Off"; $("settings-sounds").classList.toggle("active", prefs.sounds); }); // Password change $("settings-change-pw").addEventListener("click", () => { const oldPw = $("settings-old-pw").value; const newPw = $("settings-new-pw").value; const confirmPw = $("settings-confirm-pw").value; const msgEl = $("settings-pw-msg"); if (!oldPw || !newPw) { msgEl.textContent = "Please fill in both fields."; msgEl.className = "settings-msg error"; msgEl.classList.remove("hidden"); return; } if (newPw.length < 6) { msgEl.textContent = "New password must be at least 6 characters."; msgEl.className = "settings-msg error"; msgEl.classList.remove("hidden"); return; } if (newPw !== confirmPw) { msgEl.textContent = "Passwords do not match."; msgEl.className = "settings-msg error"; msgEl.classList.remove("hidden"); return; } socket.emit("change_password", { old_password: oldPw, new_password: newPw }); }); socket.on("password_changed", (data) => { const msgEl = $("settings-pw-msg"); if (data.success) { msgEl.textContent = "✓ Password updated successfully."; msgEl.className = "settings-msg success"; $("settings-old-pw").value = ""; $("settings-new-pw").value = ""; $("settings-confirm-pw").value = ""; } else { msgEl.textContent = data.msg || "Failed to change password."; msgEl.className = "settings-msg error"; } msgEl.classList.remove("hidden"); }); // Violet memory reset from settings $("settings-violet-reset").addEventListener("click", () => { socket.emit("violet_reset"); $("settings-violet-reset").textContent = "Memory Cleared!"; setTimeout(() => { $("settings-violet-reset").textContent = "Reset Violet Memory"; }, 2000); }); // Premium button (placeholder) $("settings-upgrade").addEventListener("click", () => { alert("Premium subscriptions are coming soon! Stay tuned."); }); function escapeHTML(str) { const p = document.createElement("p"); p.textContent = str; return p.innerHTML; } // ── Role updated (live notification) ────────────────────────────────────── socket.on("role_updated", (data) => { state.role = data.role; state.isAdmin = ["mod", "admin", "root"].includes(data.role); const adminBtn = $("admin-btn"); if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin); }); // ── Admin Panel ─────────────────────────────────────────────────────────── const adminModal = $("admin-modal"); const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 }; if ($("admin-btn")) { $("admin-btn").onclick = () => { adminModal.classList.remove("hidden"); socket.emit("admin_get_users"); socket.emit("admin_get_bans"); socket.emit("admin_get_mutes"); }; } $("close-admin").onclick = () => adminModal.classList.add("hidden"); adminModal.addEventListener("click", (e) => { if (e.target === adminModal) adminModal.classList.add("hidden"); }); // Admin tab switching document.querySelectorAll(".admin-tab").forEach(tab => { tab.addEventListener("click", () => { document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active")); tab.classList.add("active"); $("atab-" + tab.dataset.atab).classList.add("active"); }); }); // Admin toast helper function adminToast(msg) { const t = $("admin-toast"); t.textContent = msg; t.classList.remove("hidden"); setTimeout(() => t.classList.add("hidden"), 2500); } socket.on("admin_action_ok", (data) => { adminToast(data.msg); // Refresh the admin user list so buttons update socket.emit("admin_get_users"); socket.emit("admin_get_bans"); socket.emit("admin_get_mutes"); }); // ── Users pane ─────────────────────────────────────────────────────────── let adminUserCache = []; socket.on("admin_users", (data) => { adminUserCache = data.users; renderAdminUsers(adminUserCache); }); $("admin-user-search").addEventListener("input", (e) => { const q = e.target.value.toLowerCase(); renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q))); }); function renderAdminUsers(users) { const list = $("admin-user-list"); list.innerHTML = ""; const myPower = ROLE_POWER[state.role] || 0; users.forEach(u => { const row = document.createElement("div"); row.className = "admin-user-row"; const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin; const canVerify = myPower >= ROLE_POWER.mod; const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root"; // Build role selector let roleHTML = ""; if (canEditRole) { const opts = ["user", "mod"]; if (myPower >= ROLE_POWER.root) opts.push("admin"); roleHTML = ``; } else { roleHTML = `${u.role}`; } row.innerHTML = ` ${escapeHTML(u.username)} ${roleHTML} ${u.online ? 'online' : ''} ${u.is_verified ? 'verified' : 'unverified'} ${u.has_ai_access ? 'AI' : ''} ${canVerify ? `` : ''} ${canToggleAI ? `` : ''} `; list.appendChild(row); }); // Event delegation for actions list.onclick = (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; const uid = parseInt(btn.dataset.uid); const action = btn.dataset.action; if (action === "verify") socket.emit("admin_verify_user", { user_id: uid }); else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid }); }; list.onchange = (e) => { const sel = e.target.closest("[data-action='set-role']"); if (!sel) return; const uid = parseInt(sel.dataset.uid); socket.emit("admin_set_role", { user_id: uid, role: sel.value }); }; } // ── Bans pane ──────────────────────────────────────────────────────────── socket.on("admin_bans", (data) => { const list = $("admin-ban-list"); const empty = $("admin-no-bans"); list.innerHTML = ""; empty.classList.toggle("hidden", data.bans.length > 0); data.bans.forEach(b => { const row = document.createElement("div"); row.className = "admin-ban-row"; row.innerHTML = `
${escapeHTML(b.username)} ${b.ip ? `${b.ip}` : ''} ${b.created_at}
`; list.appendChild(row); }); list.onclick = (e) => { const btn = e.target.closest("[data-bid]"); if (!btn) return; socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) }); btn.closest(".admin-ban-row").remove(); }; }); // ── Mutes pane ─────────────────────────────────────────────────────────── socket.on("admin_mutes", (data) => { const list = $("admin-mute-list"); const empty = $("admin-no-mutes"); list.innerHTML = ""; empty.classList.toggle("hidden", data.mutes.length > 0); data.mutes.forEach(m => { const row = document.createElement("div"); row.className = "admin-mute-row"; row.innerHTML = `
${escapeHTML(m.username)} ${m.created_at}
`; list.appendChild(row); }); list.onclick = (e) => { const btn = e.target.closest("[data-mid]"); if (!btn) return; socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) }); btn.closest(".admin-mute-row").remove(); }; });