aprhodite/static/chat.js

1026 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `<span>${title}</span>`;
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 = `
<div id="messages-${room}" class="messages" role="log"></div>
${otherUser.toLowerCase() === 'violet' ? `<div id="typing-${room}" class="typing-indicator hidden">Violet is typing...</div>` : ''}
`;
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, "<strong>$1</strong>");
} else {
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
div.innerHTML = `
<div class="msg-meta">${formatTs(msg.ts)} ${msg.sender}</div>
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
`;
}
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 = `
<span class="${isUnverified ? 'unverified' : ''}">
${roleIcon ? `<span class="mod-star">${roleIcon}</span> ` : ''}
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
${isIgnored ? ' <small>(ignored)</small>' : ''}
${isUnverified ? ' <small>(unverified)</small>' : ''}
</span>
`;
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 = `<select class="au-select" data-uid="${u.id}" data-action="set-role">
${opts.map(r => `<option value="${r}" ${u.role === r ? "selected" : ""}>${r}</option>`).join("")}
</select>`;
} else {
roleHTML = `<span class="au-role ${u.role}">${u.role}</span>`;
}
row.innerHTML = `
<span class="au-name">${escapeHTML(u.username)}</span>
${roleHTML}
<span class="au-badges">
${u.online ? '<span class="au-badge online">online</span>' : ''}
${u.is_verified ? '<span class="au-badge verified">verified</span>' : '<span class="au-badge unverified">unverified</span>'}
${u.has_ai_access ? '<span class="au-badge ai-on">AI</span>' : ''}
</span>
<span class="au-actions">
${canVerify ? `<button class="au-btn" data-uid="${u.id}" data-action="verify">${u.is_verified ? 'Unverify' : 'Verify'}</button>` : ''}
${canToggleAI ? `<button class="au-btn" data-uid="${u.id}" data-action="toggle-ai">${u.has_ai_access ? 'Revoke AI' : 'Grant AI'}</button>` : ''}
</span>
`;
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 = `
<div>
<span class="ab-name">${escapeHTML(b.username)}</span>
${b.ip ? `<small style="color:var(--text-dim);margin-left:8px">${b.ip}</small>` : ''}
<small style="color:var(--text-dim);margin-left:8px">${b.created_at}</small>
</div>
<button class="au-btn danger" data-bid="${b.id}">Unban</button>
`;
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 = `
<div>
<span class="am-name">${escapeHTML(m.username)}</span>
<small style="color:var(--text-dim);margin-left:8px">${m.created_at}</small>
</div>
<button class="au-btn danger" data-mid="${m.id}">Unmute</button>
`;
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();
};
});