const api = window.dashboardApi; const $ = (id) => document.getElementById(id); const state = { guilds: [], botGuildIds: new Set(), botStatusKnown: false, selectedGuildId: null, roles: [], commands: [], baseCommands: [], permissions: null, originalPermissions: null, botInstalled: false, dirty: false, loading: false, saving: false, tab: "settings", selectedWarningUser: null, warningBusy: false, searchVersion: 0, }; const pages = { settings: "Overview", permissions: "Permissions", warnings: "Warnings", }; const clone = (value) => JSON.parse(JSON.stringify(value)); const node = (tag, className, text) => { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; }; const normalizeEntry = (entry = {}) => ({ allowedRoleIds: Array.isArray(entry.allowedRoleIds) ? [...entry.allowedRoleIds] : [], allowedUserIds: Array.isArray(entry.allowedUserIds) ? [...entry.allowedUserIds] : [], restricted: Boolean( entry.allowedRoleIds?.length || entry.allowedUserIds?.length, ), }); const normalizePermissions = (permissions = {}) => ({ adminRoleIds: [...(permissions.adminRoleIds || [])], adminUserIds: [...(permissions.adminUserIds || [])], commandPermissions: Object.fromEntries( Object.entries(permissions.commandPermissions || {}).map( ([name, entry]) => [name, normalizeEntry(entry)], ), ), }); const canonicalEntry = (entry = {}) => JSON.stringify({ roles: [...(entry.allowedRoleIds || [])].sort(), users: [...(entry.allowedUserIds || [])].sort(), restricted: Boolean(entry.restricted), }); const commandChanged = (name) => canonicalEntry(state.permissions?.commandPermissions[name]) !== canonicalEntry(state.originalPermissions?.commandPermissions[name]) || !state.baseCommands.includes(name); const permissionSignature = (permissions) => JSON.stringify({ roles: [...(permissions?.adminRoleIds || [])].sort(), users: [...(permissions?.adminUserIds || [])].sort(), commands: state.commands .map((name) => [ name, canonicalEntry(permissions?.commandPermissions[name]), ]) .sort(), }); const setError = (error) => { if (!error) return api.clearErrorBanner($("errorBanner")); api.setErrorBanner( $("errorBanner"), error.message || "Please try again.", error.payload || { message: error.message }, ); }; const parseIds = (value) => [...new Set(value.split(/[\s,]+/).filter(Boolean))]; const validIds = (ids) => ids.every((id) => /^\d{17,20}$/.test(id)); const readIds = (input) => { const ids = parseIds(input.value.trim()); if (!validIds(ids)) { setError({ message: "Use Discord user IDs with 17–20 digits, separated by spaces or commas.", }); input.focus(); return []; } setError(null); return ids; }; const updateSummary = () => { const ready = Boolean(state.permissions); const total = state.commands.length; const restricted = state.commands.filter( (name) => state.permissions?.commandPermissions[name]?.restricted, ).length; const changed = state.commands.filter(commandChanged).length; $("navCommandCount").textContent = ready ? total : "–"; $("overviewCommandCount").textContent = ready ? total : "–"; $("overviewRoleCount").textContent = ready ? state.permissions.adminRoleIds.length : "–"; $("overviewRestrictedCount").textContent = ready ? restricted : "–"; $("permissionTotalCount").textContent = total; $("permissionRestrictedCount").textContent = restricted; $("permissionOpenCount").textContent = total - restricted; $("permissionChangedCount").textContent = changed; document .querySelectorAll(".command-card") .forEach((card) => card.classList.toggle( "command-card--changed", commandChanged(card.dataset.command), ), ); }; const updateDirty = () => { state.dirty = Boolean( state.permissions && state.originalPermissions && (permissionSignature(state.permissions) !== permissionSignature(state.originalPermissions) || state.commands.some((name) => !state.baseCommands.includes(name))), ); $("saveBar").hidden = !state.dirty; updateSummary(); }; const syncVisibility = () => { const ready = Boolean(state.permissions) && !state.loading; ["settings", "permissions", "warnings"].forEach((name) => { $(`${name}Panel`).hidden = !ready || state.tab !== name; }); $("overviewStats").hidden = state.tab !== "settings"; $("contentSkeleton").hidden = !state.loading; $("workspaceEmpty").hidden = Boolean(state.selectedGuildId) || state.loading; $("botInactiveCallout").hidden = !state.selectedGuildId || state.botInstalled || !state.botStatusKnown; $("inviteButton").hidden = $("botInactiveCallout").hidden; $("saveBar").hidden = !state.dirty; $("savePermissionsButton").disabled = state.saving || state.loading; $("discardChangesButton").disabled = state.saving || state.loading; }; const switchTab = (name) => { if (!pages[name]) return; state.tab = name; const label = pages[name]; $("currentPage").textContent = label; $("pageTitle").textContent = label; document.title = `${label} · The Steward`; document.querySelectorAll(".tab").forEach((tab) => { tab.classList.toggle("active", tab.dataset.tab === name); if (tab.dataset.tab === name) tab.setAttribute("aria-current", "page"); else tab.removeAttribute("aria-current"); }); syncVisibility(); }; const initials = (name) => name .trim() .split(/\s+/) .slice(0, 2) .map((word) => word[0]) .join("") .toUpperCase(); const updateGuildHeader = () => { const guild = state.guilds.find( (entry) => entry.id === state.selectedGuildId, ); $("guildHeaderName").textContent = guild?.name || "Select a server"; $("guildHeaderFallback").textContent = initials(guild?.name || "Steward"); $("guildHeaderIcon").hidden = !guild?.icon; $("guildHeaderFallback").hidden = Boolean(guild?.icon); if (guild?.icon) $("guildHeaderIcon").src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`; else $("guildHeaderIcon").removeAttribute("src"); const known = guild && state.botStatusKnown; $("overviewBotStatus").textContent = !guild ? "–" : !known ? "Unknown" : state.botInstalled ? "Added" : "Not added"; $("overviewBotStatus").classList.toggle( "is-connected", Boolean(known && state.botInstalled), ); $("refreshBotButton").hidden = !guild; }; const renderGuilds = () => { const query = $("guildSearch").value.trim().toLowerCase(); const guilds = state.guilds.filter((guild) => guild.name.toLowerCase().includes(query), ); $("guildCount").textContent = state.guilds.length.toString().padStart(2, "0"); $("guildList").replaceChildren(); $("guildEmpty").hidden = guilds.length > 0; $("guildEmpty").textContent = query ? "No matching servers." : "No servers available. Manage Server permission required."; guilds.forEach((guild) => { const button = node( "button", `guild-item${guild.id === state.selectedGuildId ? " active" : ""}`, ); button.type = "button"; button.disabled = state.loading || state.saving; button.setAttribute( "aria-pressed", String(guild.id === state.selectedGuildId), ); const info = node("span", "guild-item__info"); const fallback = node("span", "guild-avatar", initials(guild.name)); if (guild.icon) { const img = node("img"); img.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`; img.alt = ""; img.addEventListener("error", () => img.replaceWith(fallback), { once: true, }); info.append(img); } else info.append(fallback); info.append(node("span", "guild-item__name", guild.name)); const installed = state.botGuildIds.has(guild.id); const status = !state.botStatusKnown ? "Status unavailable" : installed ? "Bot added" : "Not invited"; button.title = `${guild.name} · ${status}`; const badge = node( "span", `status-pill${installed ? " status-pill--ok" : ""}`, ); badge.setAttribute("aria-label", status); badge.append(node("span", "status-pill__dot")); button.append(info, badge); button.addEventListener("click", () => selectGuild(guild.id)); $("guildList").append(button); }); }; const renderChips = (container, ids, nameForId, onRemove, disabled = false) => { container.replaceChildren(); container.hidden = !ids.length; ids.forEach((id) => { const chip = node("span", "chip", nameForId(id)); const remove = node("button", "", "×"); remove.type = "button"; remove.disabled = disabled; remove.setAttribute("aria-label", `Remove ${nameForId(id)}`); remove.addEventListener("click", () => onRemove(id)); chip.append(remove); container.append(chip); }); }; const renderRolePicker = (container, ids, onChange, label) => { container.replaceChildren(); const wrapper = node("div", "multi-picker"); const search = node("input", "multi-picker__search"); search.type = "search"; search.placeholder = "Find a role…"; search.setAttribute("aria-label", `Search ${label}`); search.disabled = !state.botInstalled; const list = node("div", "multi-picker__list"); const chips = node("div", "chip-list"); const refreshChips = () => { renderChips( chips, ids, (id) => state.roles.find((role) => role.id === id)?.name || `Unavailable role · ${id}`, (id) => { ids.splice(ids.indexOf(id), 1); onChange(); refreshList(); refreshChips(); }, !state.botInstalled, ); }; const refreshList = () => { list.replaceChildren(); const roles = state.roles .filter((role) => role.name.toLowerCase().includes(search.value.toLowerCase()), ) .sort((a, b) => b.position - a.position); if (!roles.length) list.append( node( "span", "field-hint", state.botInstalled ? "No matching roles." : "Server roles are unavailable.", ), ); roles.forEach((role) => { const row = node("label"); const checkbox = node("input"); checkbox.type = "checkbox"; checkbox.checked = ids.includes(role.id); checkbox.disabled = !state.botInstalled; checkbox.addEventListener("change", () => { if (checkbox.checked) ids.push(role.id); else ids.splice(ids.indexOf(role.id), 1); onChange(); refreshChips(); }); const dot = node("span", "role-dot"); if ( Number.isInteger(role.color) && role.color > 0 && role.color <= 0xffffff ) dot.style.setProperty( "--role-color", `#${role.color.toString(16).padStart(6, "0")}`, ); row.append(checkbox, dot, document.createTextNode(role.name)); list.append(row); }); }; search.addEventListener("input", refreshList); wrapper.append(search, list, chips); container.append(wrapper); refreshList(); refreshChips(); }; const ensureCommands = () => { state.commands = [ ...new Set([ ...state.commands, ...Object.keys(state.permissions.commandPermissions), ]), ].sort(); const entries = state.permissions.commandPermissions; state.permissions.commandPermissions = Object.fromEntries( state.commands.map((name) => [ name, Object.hasOwn(entries, name) ? entries[name] : normalizeEntry(), ]), ); }; const renderAdminUsers = () => renderChips( $("adminUserChips"), state.permissions.adminUserIds, (id) => id, (id) => { state.permissions.adminUserIds = state.permissions.adminUserIds.filter( (entry) => entry !== id, ); updateDirty(); renderAdminUsers(); }, !state.botInstalled, ); const renderCommands = () => { $("commandsList").replaceChildren(); if (!state.permissions) return; const query = $("commandSearch") .value.trim() .toLowerCase() .replace(/^\//, ""); const filter = $("commandFilter").value; const names = state.commands.filter( (name) => name.toLowerCase().includes(query) && (filter === "all" || (filter === "restricted" && state.permissions.commandPermissions[name].restricted) || (filter === "open" && !state.permissions.commandPermissions[name].restricted) || (filter === "changed" && commandChanged(name))), ); $("commandsEmpty").hidden = names.length > 0; names.forEach((name) => { const config = state.permissions.commandPermissions[name]; const card = node( "article", `command-card${commandChanged(name) ? " command-card--changed" : ""}`, ); card.dataset.command = name; const header = node("div", "command-card__header"); const title = node("div", "command-card__title"); title.append(node("strong", "", `/${name}`)); const toggleLabel = node("label", "toggle"); const toggle = node("input"); toggle.type = "checkbox"; toggle.checked = config.restricted; toggle.disabled = !state.botInstalled; toggle.setAttribute("aria-label", `Restrict /${name}`); toggle.id = `restrict-${name}`; toggleLabel.append(document.createTextNode("Restricted"), toggle); header.append(title, toggleLabel); card.append(header); toggle.addEventListener("change", () => { config.restricted = toggle.checked; if (!toggle.checked) { config.allowedRoleIds = []; config.allowedUserIds = []; } updateDirty(); renderCommands(); document .getElementById(`restrict-${name}`) ?.focus({ preventScroll: true }); }); if (config.restricted) { const body = node("div", "command-card__body"); const roles = node("div", "field"); roles.append(node("span", "", "Allowed roles")); const picker = node("div"); roles.append(picker); const note = node("p", "command-card__note"); const updateNote = () => { note.hidden = Boolean( config.allowedRoleIds.length || config.allowedUserIds.length, ); note.textContent = "Select at least one role or member."; }; renderRolePicker( picker, config.allowedRoleIds, () => { updateDirty(); updateNote(); }, `roles for /${name}`, ); const users = node("div", "field"); const label = node("label", "", "Allowed members"); const input = node("input"); input.type = "text"; input.placeholder = "Paste Discord user IDs"; input.id = `users-${name}`; input.disabled = !state.botInstalled; label.htmlFor = input.id; const add = node("button", "button", "Add"); add.type = "button"; add.disabled = !state.botInstalled; const row = node("div", "input-row"); row.append(input, add); const chips = node("div", "chip-list"); const refreshUsers = () => renderChips( chips, config.allowedUserIds, (id) => id, (id) => { config.allowedUserIds = config.allowedUserIds.filter( (entry) => entry !== id, ); updateDirty(); refreshUsers(); updateNote(); }, !state.botInstalled, ); const addUsers = () => { const ids = readIds(input); if (!ids.length) return; config.allowedUserIds = [ ...new Set([...config.allowedUserIds, ...ids]), ]; input.value = ""; updateDirty(); refreshUsers(); updateNote(); }; add.addEventListener("click", addUsers); input.addEventListener("keydown", (event) => { if (event.key === "Enter") addUsers(); }); users.append(label, row, chips); refreshUsers(); updateNote(); body.append(roles, users, note); card.append(body); } $("commandsList").append(card); }); updateSummary(); }; const renderPanels = () => { if (!state.permissions) return; renderRolePicker( $("adminRolesPicker"), state.permissions.adminRoleIds, updateDirty, "admin roles", ); renderAdminUsers(); renderCommands(); updateSummary(); [ "adminUserInput", "addAdminUserButton", "newCommandInput", "addCommandButton", "clearAllCommands", "copyFromGuild", ].forEach((id) => { $(id).disabled = !state.botInstalled; }); $("copyFromGuild").disabled = !state.botInstalled || state.guilds.length < 2; $("warningsUserSearch").disabled = !state.botInstalled; syncVisibility(); }; const loadBotGuilds = async () => { try { const data = await api.fetchJson("/api/bot/guilds"); state.botGuildIds = new Set(data.guildIds || []); state.botStatusKnown = true; } catch (error) { state.botStatusKnown = false; setError(error); } }; const resetWarningHistory = () => { $("warningsList").replaceChildren(); $("warningsTotalCount").textContent = "–"; $("warningsLatestDate").textContent = "–"; $("warningsEmpty").hidden = false; $("warningsEmpty").textContent = "Select a member to view warnings."; }; const warningTarget = () => $("warningsUserInput").value.trim(); const setWarningTarget = (member = null) => { state.selectedWarningUser = member; if (member) $("warningsUserInput").value = member.id; const id = warningTarget(); $("warningsSelectedUser").textContent = member ? member.nick || member.user?.global_name || member.user?.username || member.id : id || "No member selected"; $("warningsSelectedCard").hidden = !id; $("warningsSelectedHint").textContent = member ? id : ""; $("warningReasonInput").value = ""; $("warningStatus").textContent = ""; updateWarningCount(); resetWarningHistory(); document .querySelectorAll(".member-item") .forEach((button) => button.classList.toggle("active", button.dataset.userId === id), ); }; const resetWarningSelection = () => { state.searchVersion += 1; $("warningsUserInput").value = ""; $("warningsUserSearch").value = ""; $("warningsUserResults").replaceChildren(); $("warningsSearchStatus").textContent = ""; setWarningTarget(); }; const loadGuildDetails = async () => { if (!state.selectedGuildId) { syncVisibility(); return; } state.loading = true; state.permissions = null; state.originalPermissions = null; state.commands = []; state.roles = []; state.dirty = false; state.botInstalled = state.botGuildIds.has(state.selectedGuildId) && state.botStatusKnown; resetWarningSelection(); updateGuildHeader(); updateSummary(); syncVisibility(); renderGuilds(); try { const [permissions, commands] = await Promise.all([ api.fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`), api.fetchJson(`/api/guilds/${state.selectedGuildId}/commands`), ]); if (state.botInstalled) { try { state.roles = (await api.fetchJson(`/api/guilds/${state.selectedGuildId}/roles`)) .roles || []; } catch (error) { if (error.code === "BOT_NOT_IN_GUILD") { state.botInstalled = false; state.botGuildIds.delete(state.selectedGuildId); } else setError(error); } } state.permissions = normalizePermissions(permissions.permissions || {}); state.commands = commands.commands || []; ensureCommands(); state.baseCommands = [...state.commands]; state.originalPermissions = clone(state.permissions); renderPanels(); updateGuildHeader(); } catch (error) { setError(error); } finally { state.loading = false; syncVisibility(); renderGuilds(); } }; const selectGuild = async (id) => { if (state.loading || state.saving || state.warningBusy) return; if ( state.dirty && !window.confirm("Discard your unsaved changes and switch servers?") ) return; setError(null); state.selectedGuildId = id; await loadGuildDetails(); }; const savePermissions = async () => { if (!state.permissions || state.saving || state.loading || !state.dirty) return; const incomplete = state.commands.find((name) => { const entry = state.permissions.commandPermissions[name]; return ( entry.restricted && !entry.allowedRoleIds.length && !entry.allowedUserIds.length ); }); if (incomplete) { switchTab("permissions"); $("commandSearch").value = incomplete; $("commandFilter").value = "all"; renderCommands(); setError({ message: `Select a role or member for /${incomplete}, or turn off its restriction before saving.`, }); return; } state.saving = true; state.loading = true; syncVisibility(); renderGuilds(); $("savePermissionsButton").textContent = "Saving…"; try { const payload = { adminRoleIds: state.permissions.adminRoleIds, adminUserIds: state.permissions.adminUserIds, commandPermissions: Object.fromEntries( state.commands.map((name) => { const { allowedRoleIds, allowedUserIds } = state.permissions.commandPermissions[name]; return [name, { allowedRoleIds, allowedUserIds }]; }), ), }; const data = await api.fetchJson( `/api/guilds/${state.selectedGuildId}/permissions`, { method: "PATCH", body: JSON.stringify(payload) }, ); state.permissions = normalizePermissions(data.permissions || payload); ensureCommands(); state.originalPermissions = clone(state.permissions); state.baseCommands = [...state.commands]; updateDirty(); renderPanels(); setError(null); api.showToast("Changes saved.", "success"); } catch (error) { setError(error); } finally { state.saving = false; state.loading = false; $("savePermissionsButton").textContent = "Save changes"; syncVisibility(); renderGuilds(); } }; const discardChanges = () => { if (!state.originalPermissions || state.saving) return; state.permissions = clone(state.originalPermissions); state.commands = [...state.baseCommands]; updateDirty(); renderPanels(); setError(null); }; const addAdminUser = () => { if (!state.permissions || !state.botInstalled) return; const ids = readIds($("adminUserInput")); if (!ids.length) return; state.permissions.adminUserIds = [ ...new Set([...state.permissions.adminUserIds, ...ids]), ]; $("adminUserInput").value = ""; updateDirty(); renderAdminUsers(); }; const addCommand = () => { if (!state.permissions || !state.botInstalled) return; const name = $("newCommandInput") .value.trim() .toLowerCase() .replace(/^\//, ""); if (!/^[a-z0-9_-]{1,32}$/.test(name)) { setError({ message: "Use a command name of 1–32 letters, numbers, hyphens or underscores.", }); return; } if (!state.commands.includes(name)) { state.commands.push(name); ensureCommands(); } $("newCommandInput").value = ""; $("commandSearch").value = name; $("commandFilter").value = "all"; updateDirty(); renderCommands(); setError(null); }; const searchWarningMembers = async () => { const query = $("warningsUserSearch").value.trim(); const version = ++state.searchVersion; const guildId = state.selectedGuildId; $("warningsUserResults").replaceChildren(); if (!guildId || !state.botInstalled || query.length < 2) { $("warningsSearchStatus").textContent = query ? "Enter at least 2 characters." : ""; return; } $("warningsSearchStatus").textContent = "Looking up members…"; try { const data = await api.fetchJson( `/api/guilds/${guildId}/members?query=${encodeURIComponent(query)}`, ); if (version !== state.searchVersion || guildId !== state.selectedGuildId) return; const members = data.members || []; $("warningsSearchStatus").textContent = members.length ? `${members.length} member${members.length === 1 ? "" : "s"} found` : "No matching members."; members.forEach((member) => { const button = node( "button", `member-item${warningTarget() === member.id ? " active" : ""}`, ); button.type = "button"; button.dataset.userId = member.id; const meta = node("span", "member-item__meta"); meta.append( node( "span", "member-item__name", member.nick || member.user?.global_name || member.user?.username || member.id, ), node("span", "member-item__id", member.id), ); button.append(meta); button.addEventListener("click", () => { if (!state.warningBusy) setWarningTarget(member); }); $("warningsUserResults").append(button); }); } catch (error) { if (version !== state.searchVersion || guildId !== state.selectedGuildId) return; $("warningsSearchStatus").textContent = "Search unavailable. Enter a user ID instead."; setError(error); } }; const renderWarnings = (warnings) => { $("warningsList").replaceChildren(); $("warningsEmpty").hidden = warnings.length > 0; $("warningsEmpty").textContent = "No warnings."; $("warningsTotalCount").textContent = warnings.length; const dates = warnings .filter((warning) => warning.createdAt) .map((warning) => new Date(warning.createdAt)) .filter((date) => !Number.isNaN(date.getTime())); $("warningsLatestDate").textContent = dates.length ? new Date(Math.max(...dates)).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric", }) : "None"; [...warnings].reverse().forEach((warning) => { const item = node("li", "warning-item"); const meta = node("div", "warning-item__meta"); meta.append( node("span", "", `Moderator: ${warning.moderatorId || "Unknown"}`), node( "span", "", warning.createdAt ? new Date(warning.createdAt).toLocaleString() : "Unknown date", ), ); const copy = node("button", "button button--ghost", "Copy moderator ID"); copy.type = "button"; copy.disabled = !warning.moderatorId; copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(warning.moderatorId); api.showToast("Moderator ID copied."); } catch { api.showToast("Unable to copy the moderator ID.", "error"); } }); item.append(node("p", "", warning.reason), meta, copy); $("warningsList").append(item); }); }; const updateWarningCount = () => { $("warningCharCount").textContent = `${$("warningReasonInput").value.length}/500`; }; const warningAction = async (method = "GET") => { if (!state.selectedGuildId || state.warningBusy || state.loading) return; const id = warningTarget(); if (!/^\d{17,20}$/.test(id)) { setError({ message: "Select a member or enter a valid Discord user ID (17–20 digits).", }); $("warningsUserInput").focus(); return; } const reason = $("warningReasonInput").value.trim(); if (method === "POST" && (!reason || reason.length > 500)) { $("warningStatus").textContent = "Please enter a reason of 1–500 characters."; $("warningReasonInput").focus(); return; } if ( method === "DELETE" && !window.confirm( `Clear all warnings for member ${id}? This cannot be undone.`, ) ) return; const guildId = state.selectedGuildId; state.warningBusy = true; $("warningsSpinner").hidden = false; const locked = [ "fetchWarningsButton", "clearWarningsButton", "addWarningButton", "warningsUserInput", "warningsUserSearch", "warningReasonInput", ]; const previousDisabled = locked.map((key) => $(key).disabled); locked.forEach((key) => { $(key).disabled = true; }); try { const data = await api.fetchJson(`/api/guilds/${guildId}/warnings/${id}`, { method, ...(method === "POST" ? { body: JSON.stringify({ reason }) } : {}), }); if (guildId !== state.selectedGuildId || id !== warningTarget()) return; renderWarnings(data.warnings || []); setError(null); if (method !== "GET") { const message = method === "POST" ? "Warning added to the member record." : "Warnings cleared."; $("warningStatus").textContent = message; api.showToast(message); if (method === "POST") { $("warningReasonInput").value = ""; updateWarningCount(); } } } catch (error) { setError(error); } finally { state.warningBusy = false; $("warningsSpinner").hidden = true; locked.forEach((key, i) => { $(key).disabled = previousDisabled[i]; }); } }; const refreshBotStatus = async () => { if (state.loading || state.saving || state.warningBusy) return; if ( state.dirty && !window.confirm("Discard unsaved changes and refresh this server?") ) return; state.loading = true; syncVisibility(); renderGuilds(); $("refreshBotButton").disabled = true; setError(null); await loadBotGuilds(); await loadGuildDetails(); $("refreshBotButton").disabled = false; }; const openInvite = async () => { $("inviteButton").disabled = true; try { const data = await api.fetchJson( `/api/invite-url?guildId=${state.selectedGuildId}`, ); if (data.inviteUrl) window.location.assign(data.inviteUrl); } catch (error) { setError(error); } finally { $("inviteButton").disabled = false; } }; const openCopyModal = () => { $("copyGuildSelect").replaceChildren(); state.guilds .filter((guild) => guild.id !== state.selectedGuildId) .forEach((guild) => { const option = node("option", "", guild.name); option.value = guild.id; $("copyGuildSelect").append(option); }); $("confirmCopyButton").disabled = !$("copyGuildSelect").options.length; $("copyModal").showModal(); }; const confirmCopy = async () => { const guildId = $("copyGuildSelect").value; if (!guildId || !state.permissions) return; const targetGuildId = state.selectedGuildId; $("confirmCopyButton").disabled = true; try { const data = await api.fetchJson(`/api/guilds/${guildId}/permissions`); if (state.selectedGuildId !== targetGuildId || !state.permissions) return; state.permissions = normalizePermissions(data.permissions || {}); ensureCommands(); updateDirty(); renderPanels(); api.showToast("Permissions copied. Review before saving."); $("copyModal").close(); } catch (error) { $("copyModal").close(); setError(error); } finally { $("confirmCopyButton").disabled = false; } }; const initDashboard = async () => { document .querySelectorAll(".tab") .forEach((tab) => tab.addEventListener("click", () => switchTab(tab.dataset.tab)), ); document.querySelectorAll("[data-open-tab]").forEach((button) => button.addEventListener("click", () => { switchTab(button.dataset.openTab); $("mainContent").focus({ preventScroll: true }); window.scrollTo({ top: 0, behavior: "instant" }); }), ); $("guildSearch").addEventListener("input", renderGuilds); $("commandSearch").addEventListener("input", renderCommands); $("commandFilter").addEventListener("change", renderCommands); $("addAdminUserButton").addEventListener("click", addAdminUser); $("adminUserInput").addEventListener("keydown", (event) => { if (event.key === "Enter") addAdminUser(); }); $("addCommandButton").addEventListener("click", addCommand); $("newCommandInput").addEventListener("keydown", (event) => { if (event.key === "Enter") addCommand(); }); $("savePermissionsButton").addEventListener("click", savePermissions); $("discardChangesButton").addEventListener("click", discardChanges); $("fetchWarningsButton").addEventListener("click", () => warningAction()); $("addWarningButton").addEventListener("click", () => warningAction("POST")); $("clearWarningsButton").addEventListener("click", () => warningAction("DELETE"), ); $("warningReasonInput").addEventListener("input", updateWarningCount); const debouncedSearch = api.debounce(searchWarningMembers, 250); $("warningsUserSearch").addEventListener("input", () => { state.searchVersion += 1; debouncedSearch(); }); $("warningsUserInput").addEventListener("input", () => setWarningTarget()); $("inviteButton").addEventListener("click", openInvite); $("refreshBotButton").addEventListener("click", refreshBotStatus); $("clearAllCommands").addEventListener("click", () => { if (!state.permissions || !state.botInstalled) return; if ( !window.confirm( "Reset all custom command restrictions? Review and save to apply this change.", ) ) return; state.commands.forEach((name) => { state.permissions.commandPermissions[name] = normalizeEntry(); }); updateDirty(); renderCommands(); }); $("copyFromGuild").addEventListener("click", openCopyModal); $("cancelCopyButton").addEventListener("click", () => $("copyModal").close()); $("confirmCopyButton").addEventListener("click", confirmCopy); $("guildHeaderIcon").addEventListener("error", () => { $("guildHeaderIcon").hidden = true; $("guildHeaderFallback").hidden = false; }); $("logoutButton").addEventListener("click", async () => { if (state.saving || state.warningBusy) return; if (state.dirty && !window.confirm("Discard unsaved changes and log out?")) return; try { await api.fetchJson("/auth/logout", { method: "POST" }); state.dirty = false; window.location.href = "/"; } catch (error) { setError(error); } }); window.addEventListener("beforeunload", (event) => { if (state.dirty) { event.preventDefault(); event.returnValue = ""; } }); $("guildListSkeleton").hidden = false; $("guildList").hidden = true; try { const session = await api.fetchJson("/api/me"); api.setCsrfToken(session.csrfToken); const displayName = session.user?.global_name || api.formatUserDisplay(session.user); $("userDisplay").textContent = displayName; $("userInitial").textContent = initials(displayName); $("sessionExpiry").textContent = "Discord account"; if (session.expiresAt) $("sessionExpiry").title = `Session expires ${new Date(session.expiresAt).toLocaleString()}`; } catch (error) { if (error.status === 401 || error.status === 403) window.location.replace("/"); else setError(error); $("guildListSkeleton").hidden = true; return; } try { const [guildData] = await Promise.all([ api.fetchJson("/api/guilds"), loadBotGuilds(), ]); state.guilds = guildData.guilds || []; state.selectedGuildId = state.guilds[0]?.id || null; renderGuilds(); await loadGuildDetails(); } catch (error) { setError(error); } finally { $("guildListSkeleton").hidden = true; $("guildList").hidden = false; syncVisibility(); } }; document.addEventListener("DOMContentLoaded", initDashboard);