// static/js/03-admin.js "use strict"; window.Glow = window.Glow || {}; Glow.closeAdminPanel = () => { Glow.els.adminPanel.classList.remove("open"); Glow.els.adminPanel.setAttribute("aria-hidden", "true"); Glow.els.adminFab.focus(); }; Glow.renderAdminUsers = users => { Glow.els.adminUsers.replaceChildren(); const heading = document.createElement("h3"); heading.textContent = "Users"; Glow.els.adminUsers.append(heading); if (!users.length) { const empty = document.createElement("p"); empty.className = "admin-empty"; empty.textContent = "No users found."; Glow.els.adminUsers.append(empty); return; } for (const user of users) { const row = document.createElement("div"); row.className = "admin-user"; const details = document.createElement("div"); const name = document.createElement("div"); name.className = "admin-user-name"; name.textContent = `${user.username} · ${user.role}`; const info = document.createElement("div"); info.className = "admin-user-info"; info.textContent = `${user.status} · ${user.message_count} message(s)` + (user.restricted_until ? ` · until ${new Date(user.restricted_until).toLocaleString()}` : ""); details.append(name, info); const actions = document.createElement("div"); actions.className = "admin-user-actions"; const current = Glow.state.currentUser && Number(Glow.state.currentUser.id) === Number(user.id); if (!current && Glow.isAdmin()) { const role = document.createElement("button"); role.className = "small-button"; role.type = "button"; role.textContent = user.role === "moderator" ? "Make user" : "Make moderator"; role.addEventListener("click", () => { Glow.updateAdminUser(user.id, { role: user.role === "moderator" ? "user" : "moderator" }); }); const restrict = document.createElement("button"); restrict.className = "small-button"; restrict.type = "button"; restrict.textContent = user.status === "restricted" ? "Unrestrict" : "Restrict"; restrict.addEventListener("click", () => { Glow.toggleRestriction(user); }); const blacklist = document.createElement("button"); blacklist.className = "small-button danger"; blacklist.type = "button"; blacklist.textContent = user.status === "blacklisted" ? "Unblacklist" : "Blacklist"; blacklist.addEventListener("click", () => { Glow.toggleBlacklist(user); }); actions.append(role, restrict, blacklist); } row.append(details, actions); Glow.els.adminUsers.append(row); } }; Glow.renderAdminRooms = rooms => { Glow.els.adminRooms.replaceChildren(); const heading = document.createElement("h3"); heading.textContent = "Rooms"; Glow.els.adminRooms.append(heading); if (!rooms.length) { const empty = document.createElement("p"); empty.className = "admin-empty"; empty.textContent = "No rooms found."; Glow.els.adminRooms.append(empty); return; } for (const room of rooms) { const row = document.createElement("div"); row.className = "admin-room"; const details = document.createElement("div"); const name = document.createElement("div"); name.className = "admin-room-name"; name.textContent = `${room.name} · ${room.visibility}`; const info = document.createElement("div"); info.className = "admin-room-info"; info.textContent = `/${room.slug}` + (room.description ? ` · ${room.description}` : ""); details.append(name, info); const actions = document.createElement("div"); actions.className = "admin-room-actions"; if (Glow.isAdmin()) { const edit = document.createElement("button"); edit.className = "small-button"; edit.type = "button"; edit.textContent = "Edit"; edit.addEventListener("click", () => Glow.editRoom(room)); actions.append(edit); if (room.visibility === "private") { const members = document.createElement("button"); members.className = "small-button"; members.type = "button"; members.textContent = "Members"; members.addEventListener("click", () => { Glow.manageRoomMembers(room); }); const invite = document.createElement("button"); invite.className = "small-button"; invite.type = "button"; invite.textContent = "Invite link"; invite.addEventListener("click", () => { Glow.createInviteLink(room); }); actions.append(members, invite); } if (room.slug !== "general") { const remove = document.createElement("button"); remove.className = "small-button danger"; remove.type = "button"; remove.textContent = "Delete"; remove.addEventListener("click", () => Glow.deleteRoom(room)); actions.append(remove); } } row.append(details, actions); Glow.els.adminRooms.append(row); } }; Glow.openAdminPanel = async () => { if (!Glow.canModerate()) return; Glow.els.adminPanel.classList.add("open"); Glow.els.adminPanel.setAttribute("aria-hidden", "false"); Glow.els.adminUsers.textContent = "Loading users…"; Glow.els.adminRooms.textContent = "Loading rooms…"; try { const [usersData, roomsData] = await Promise.all([ Glow.apiRequest("/api/admin/users"), Glow.apiRequest("/api/admin/rooms") ]); Glow.renderAdminUsers(usersData.users || []); Glow.renderAdminRooms(roomsData.rooms || []); Glow.updateAdminPreferenceButtons(); setTimeout(() => Glow.els.closeAdminButton.focus(), 0); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.updateThemeButton = () => { if (!Glow.els.themeButton) return; Glow.els.themeButton.textContent = "Accent color"; }; Glow.updateAdminPreferenceButtons = () => { Glow.els.typingToggleButton.textContent = `Typing indicators: ${Glow.state.typingIndicatorsEnabled ? "on" : "off"}`; Glow.els.notificationsToggleButton.textContent = `Notifications: ${Glow.state.notificationsEnabled ? "on" : "off"}`; Glow.updateThemeButton(); }; Glow.openAccentColorPicker = async () => { const container = document.createElement("div"); container.className = "accent-picker"; const description = document.createElement("p"); description.className = "accent-picker-description"; description.textContent = "Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings."; const label = document.createElement("label"); label.className = "accent-picker-label"; label.textContent = "Accent color"; const row = document.createElement("div"); row.className = "accent-picker-row"; const colorInput = document.createElement("input"); colorInput.className = "accent-color-input"; colorInput.type = "color"; colorInput.value = Glow.normalizeAccentColor(Glow.state.accentColor) || Glow.DEFAULT_ACCENT_COLOR; colorInput.setAttribute("aria-label", "Choose accent color"); const hexInput = document.createElement("input"); hexInput.className = "field accent-hex-input"; hexInput.type = "text"; hexInput.value = colorInput.value; hexInput.maxLength = 7; hexInput.autocomplete = "off"; hexInput.autocapitalize = "off"; hexInput.spellcheck = false; hexInput.placeholder = "#f7dce8"; hexInput.setAttribute( "aria-label", "Accent color hexadecimal value" ); const preview = document.createElement("span"); preview.className = "accent-preview"; preview.style.background = colorInput.value; preview.setAttribute("aria-hidden", "true"); const error = document.createElement("div"); error.className = "accent-picker-error"; error.setAttribute("role", "status"); const updatePreview = value => { const normalized = Glow.normalizeAccentColor(value); if (!normalized) { preview.style.background = "transparent"; error.textContent = "Use a six-digit hex color, such as #7dd3fc."; return false; } preview.style.background = normalized; error.textContent = ""; if (colorInput.value !== normalized) { colorInput.value = normalized; } return true; }; colorInput.addEventListener("input", () => { const color = Glow.normalizeAccentColor(colorInput.value); if (!color) return; hexInput.value = color; updatePreview(color); }); hexInput.addEventListener("input", () => { updatePreview(hexInput.value.trim()); }); row.append(colorInput, hexInput, preview); label.append(row); container.append(description, label, error); const result = await Glow.openDialog({ title: "Accent color", body: container, confirmText: "Save color", cancelText: "Cancel" }); if (!result) return; const accent = Glow.normalizeAccentColor(hexInput.value); if (!accent) { Glow.showToast( "Choose a valid six-digit hexadecimal color.", "error" ); return; } Glow.applyAccentColor(accent); Glow.showToast("Accent color updated."); }; Glow.toggleRestriction = async user => { if (user.status === "restricted") { return Glow.updateAdminUser(user.id, { status: "active", restricted_until: null }); } const values = await Glow.openDialog({ title: `Restrict ${user.username}`, body: "Enter the restriction duration in minutes.", confirmText: "Restrict", danger: true, fields: [ { name: "minutes", label: "Duration in minutes", type: "number", value: "60", maxLength: 5 } ] }); if (!values) return; const minutes = Number(values.minutes); if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) { Glow.showToast( "Enter a duration between 1 minute and 30 days.", "error" ); return; } await Glow.updateAdminUser(user.id, { status: "restricted", restricted_until: new Date( Date.now() + minutes * 60 * 1000 ).toISOString() }); }; Glow.toggleBlacklist = async user => { const blacklisting = user.status !== "blacklisted"; const result = await Glow.openDialog({ title: blacklisting ? `Blacklist ${user.username}?` : `Unblacklist ${user.username}?`, body: blacklisting ? "The account will be denied access immediately." : "The account will be restored to active status.", confirmText: blacklisting ? "Blacklist" : "Unblacklist", danger: blacklisting }); if (!result) return; await Glow.updateAdminUser(user.id, { status: blacklisting ? "blacklisted" : "active" }); }; Glow.updateAdminUser = async (userId, changes) => { try { await Glow.apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); Glow.showToast("User updated."); await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.createRoom = async () => { const values = await Glow.openDialog({ title: "Create room", body: "Choose a room name, description, and visibility.", confirmText: "Create", fields: [ { name: "name", label: "Room name", placeholder: "Project chat", maxLength: 60 }, { name: "description", label: "Description", placeholder: "Optional room description", multiline: true, rows: 3, maxLength: 300 }, { name: "visibility", label: "Visibility: public or private", value: "public", maxLength: 7 } ] }); if (!values) return; const visibility = values.visibility.toLowerCase(); if (!["public", "private"].includes(visibility)) { Glow.showToast("Visibility must be public or private.", "error"); return; } try { await Glow.apiRequest("/api/rooms", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: values.name, description: values.description, visibility }) }); Glow.showToast("Room created."); await Glow.loadRooms(); await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.editRoom = async room => { const values = await Glow.openDialog({ title: `Edit ${room.name}`, confirmText: "Save", fields: [ { name: "name", label: "Room name", value: room.name, maxLength: 60 }, { name: "description", label: "Description", value: room.description || "", multiline: true, rows: 3, maxLength: 300 }, { name: "visibility", label: "Visibility: public or private", value: room.visibility, maxLength: 7 } ] }); if (!values) return; const visibility = values.visibility.toLowerCase(); if (!["public", "private"].includes(visibility)) { Glow.showToast("Visibility must be public or private.", "error"); return; } await Glow.updateRoom(room.slug, { name: values.name, description: values.description, visibility }); }; Glow.updateRoom = async (slug, changes) => { try { await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); Glow.showToast("Room updated."); await Glow.loadRooms(); if (!Glow.state.activeDm) { await Glow.loadMessages(); } await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.deleteRoom = async room => { const result = await Glow.openDialog({ title: `Delete ${room.name}?`, body: "This permanently deletes the room and every message in it.", confirmText: "Delete room", danger: true }); if (!result) return; try { await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}`, { method: "DELETE" }); Glow.showToast("Room deleted."); await Glow.loadRooms(); if (!Glow.state.activeDm) { await Glow.loadMessages(); } await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.clearActiveRoom = async () => { const room = Glow.state.activeRoom; if (!room || Glow.state.activeDm) return; const result = await Glow.openDialog({ title: `Clear ${room.name}?`, body: "All messages in this room will be permanently deleted.", confirmText: "Clear room", danger: true }); if (!result) return; try { await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/messages`, { method: "DELETE" } ); Glow.clearMessages(); Glow.renderEmptyState(); Glow.showToast("Room messages cleared."); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.manageRoomMembers = async room => { try { const data = await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/members` ); const container = document.createElement("div"); container.className = "member-list"; for (const member of data.members || []) { const row = document.createElement("div"); row.className = "member-row"; const name = document.createElement("span"); name.textContent = `${member.username} · ${member.membership_role}`; const remove = document.createElement("button"); remove.className = "small-button danger"; remove.type = "button"; remove.textContent = "Remove"; remove.disabled = member.membership_role === "owner"; remove.addEventListener("click", async () => { try { await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`, { method: "DELETE" } ); Glow.showToast("Member removed."); Glow.closeDialog({}); Glow.manageRoomMembers(room); } catch (error) { Glow.showToast(error.message, "error"); } }); row.append(name, remove); container.append(row); } const values = await Glow.openDialog({ title: `${room.name} members`, body: container, confirmText: "Add member", fields: [ { name: "username", label: "Username to add", placeholder: "username", maxLength: 30 } ] }); if (!values?.username) return; await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/members`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: values.username }) } ); Glow.showToast("Member added."); await Glow.manageRoomMembers(room); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.createInviteLink = async room => { try { const data = await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/invites`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) } ); const url = new URL( data.invite.url, window.location.origin ).toString(); const values = await Glow.openDialog({ title: "Private-room invite link", body: "Copy and share this link. It can be used once before it expires.", confirmText: "Copy link", fields: [ { name: "invite", label: "Invite link", value: url, maxLength: 500 } ] }); if (values) { await navigator.clipboard.writeText(url); Glow.showToast("Invite link copied."); } } catch (error) { Glow.showToast(error.message, "error"); } };