diff --git a/public/app.js b/public/app.js index b4083f3..c5911d5 100644 --- a/public/app.js +++ b/public/app.js @@ -121,7 +121,7 @@ const initIndexPage = async () => { try { const data = await fetchJson('/api/me'); setCsrfToken(data.csrfToken); - sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`; + sessionStatus.textContent = formatUserDisplay(data.user); loginButton.hidden = true; dashboardButton.hidden = false; logoutButton.hidden = false; @@ -130,26 +130,27 @@ const initIndexPage = async () => { banner.style.display = 'none'; } } catch (error) { - sessionStatus.textContent = 'You are not signed in yet.'; + sessionStatus.textContent = ''; loginButton.hidden = false; dashboardButton.hidden = true; logoutButton.hidden = true; if (banner) { - banner.textContent = 'Log in with Discord to access the dashboard.'; - banner.style.display = 'block'; + const sessionExpired = error.status === 401 || error.status === 403; + banner.textContent = sessionExpired ? '' : 'Unable to check your session. Try again.'; + banner.style.display = sessionExpired ? 'none' : 'block'; } } logoutButton?.addEventListener('click', async () => { try { - await fetch('/auth/logout', { + await fetchJson('/auth/logout', { method: 'POST', credentials: 'include', headers: { ...(csrfToken ? {'X-CSRF-Token': csrfToken} : {}), }, }); - sessionStatus.textContent = 'Logged out successfully.'; + sessionStatus.textContent = ''; loginButton.hidden = false; dashboardButton.hidden = true; logoutButton.hidden = true; diff --git a/public/concierge.png b/public/concierge.png new file mode 100644 index 0000000..4abd6cd Binary files /dev/null and b/public/concierge.png differ diff --git a/public/concierge.svg b/public/concierge.svg new file mode 100644 index 0000000..6ec869a --- /dev/null +++ b/public/concierge.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + +THE STEWARD + diff --git a/public/dashboard.html b/public/dashboard.html index bbd46c4..f3addf8 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -1,287 +1,407 @@ - - - - Discord Bot Dashboard - - - -
- - - - -
- -
- - -
-
-
- -
-

Select a guild

-
- - -
-
-
-
- - -
-
- -
- - - -
- -
-
-
-

Admin access

-

Control who can edit bot configuration and permissions.

-
-
- -
- - Admin roles - ? - -
-
-
- - Admin user IDs - ? - -
- - -
-
-
-
- - - - - - - - -
-
+ + + + + Overview · The Steward + + + + + +
+ +
+
+ + +
+
+ +
+

Overview

+
+ SSelect a server +
+
+
+
+
+ Bot status +
+ – +
+
+
+ +
+ – +
+
+
+ Admin roles +
+ – +
+
+
+ Restricted commands +
+ – +
+
+ + + + + + + +
+
+
+
+ + + + + + diff --git a/public/dashboard.js b/public/dashboard.js index 49c4042..11a2d2a 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -1,1076 +1,1083 @@ +const api = window.dashboardApi; +const $ = (id) => document.getElementById(id); const state = { - guilds: [], - filteredGuilds: [], - selectedGuildId: null, - roles: [], - permissions: null, - commands: [], - botGuildIds: new Set(), - botInstalled: false, - dirty: false, - originalPermissions: null, - selectedWarningUser: null, + 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 elements = { - errorBanner: document.getElementById('errorBanner'), - userDisplay: document.getElementById('userDisplay'), - sessionExpiry: document.getElementById('sessionExpiry'), - logoutButton: document.getElementById('logoutButton'), - guildList: document.getElementById('guildList'), - guildListSkeleton: document.getElementById('guildListSkeleton'), - guildEmpty: document.getElementById('guildEmpty'), - guildSearch: document.getElementById('guildSearch'), - guildHeaderIcon: document.getElementById('guildHeaderIcon'), - guildHeaderName: document.getElementById('guildHeaderName'), - guildStatusBadge: document.getElementById('guildStatusBadge'), - guildStatusNote: document.getElementById('guildStatusNote'), - inviteButton: document.getElementById('inviteButton'), - refreshBotButton: document.getElementById('refreshBotButton'), - tabs: document.querySelectorAll('.tab'), - settingsPanel: document.getElementById('settingsPanel'), - permissionsPanel: document.getElementById('permissionsPanel'), - warningsPanel: document.getElementById('warningsPanel'), - adminRolesPicker: document.getElementById('adminRolesPicker'), - adminUserInput: document.getElementById('adminUserInput'), - addAdminUserButton: document.getElementById('addAdminUserButton'), - adminUserChips: document.getElementById('adminUserChips'), - newCommandInput: document.getElementById('newCommandInput'), - addCommandButton: document.getElementById('addCommandButton'), - commandsList: document.getElementById('commandsList'), - commandsEmpty: document.getElementById('commandsEmpty'), - clearAllCommands: document.getElementById('clearAllCommands'), - copyFromGuild: document.getElementById('copyFromGuild'), - savePermissionsButton: document.getElementById('savePermissionsButton'), - discardChangesButton: document.getElementById('discardChangesButton'), - saveBar: document.getElementById('saveBar'), - botInactiveCallout: document.getElementById('botInactiveCallout'), - warningsUserInput: document.getElementById('warningsUserInput'), - warningsUserSearch: document.getElementById('warningsUserSearch'), - warningsUserResults: document.getElementById('warningsUserResults'), - warningsSelectedUser: document.getElementById('warningsSelectedUser'), - fetchWarningsButton: document.getElementById('fetchWarningsButton'), - clearWarningsButton: document.getElementById('clearWarningsButton'), - warningsList: document.getElementById('warningsList'), - warningsEmpty: document.getElementById('warningsEmpty'), - warningsSpinner: document.getElementById('warningsSpinner'), - newWarningUserInput: document.getElementById('newWarningUserInput'), - warningReasonInput: document.getElementById('warningReasonInput'), - warningCharCount: document.getElementById('warningCharCount'), - addWarningButton: document.getElementById('addWarningButton'), - warningStatus: document.getElementById('warningStatus'), - contentSkeleton: document.getElementById('contentSkeleton'), - copyModal: document.getElementById('copyModal'), - copyGuildSelect: document.getElementById('copyGuildSelect'), - cancelCopyButton: document.getElementById('cancelCopyButton'), - confirmCopyButton: document.getElementById('confirmCopyButton'), +const pages = { + settings: "Overview", + permissions: "Permissions", + warnings: "Warnings", }; - -const dashboardApi = window.dashboardApi; -const fetchDashboardJson = dashboardApi.fetchJson; - -const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i; - -const clonePermissions = (permissions) => JSON.parse(JSON.stringify(permissions || {})); - -const normalizePermissions = (permissions = {}) => ({ - adminRoleIds: Array.isArray(permissions.adminRoleIds) ? permissions.adminRoleIds : [], - adminUserIds: Array.isArray(permissions.adminUserIds) ? permissions.adminUserIds : [], - commandPermissions: permissions.commandPermissions || {}, +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 setLoadingGuilds = (isLoading) => { - elements.guildList.hidden = isLoading; - elements.guildListSkeleton.hidden = !isLoading; -}; - -const setLoadingDetails = (isLoading) => { - elements.contentSkeleton.hidden = !isLoading; - if (isLoading) { - elements.settingsPanel.hidden = true; - elements.permissionsPanel.hidden = true; - elements.warningsPanel.hidden = true; - return; - } - const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'settings'; - elements.settingsPanel.hidden = activeTab !== 'settings'; - elements.permissionsPanel.hidden = activeTab !== 'permissions'; - elements.warningsPanel.hidden = activeTab !== 'warnings'; -}; - -const setDirty = (isDirty) => { - state.dirty = isDirty; - elements.saveBar.hidden = !isDirty; -}; - -const parseIdList = (value) => { - if (!value) { - return []; - } - return Array.from( - new Set( - value - .split(/[\s,\n]+/) - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ); -}; - +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) { - dashboardApi.clearErrorBanner(elements.errorBanner); - return; - } - dashboardApi.setErrorBanner(elements.errorBanner, error.message || 'Something went wrong.', error.payload || error); + if (!error) return api.clearErrorBanner($("errorBanner")); + api.setErrorBanner( + $("errorBanner"), + error.message || "Please try again.", + error.payload || { message: error.message }, + ); }; - -const handleFetchError = (error, fallback) => { - if (error?.code === 'BOT_NOT_IN_GUILD') { - return; - } - setError(error || {message: fallback}); -}; - -const updateGuildHeader = (guild) => { - if (!guild) { - elements.guildHeaderName.textContent = 'Select a guild'; - elements.guildHeaderIcon.src = ''; - elements.guildStatusBadge.textContent = ''; - elements.guildStatusNote.textContent = ''; - return; - } - elements.guildHeaderName.textContent = guild.name; - if (guild.icon) { - elements.guildHeaderIcon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`; - } else { - elements.guildHeaderIcon.removeAttribute('src'); - } - if (state.botInstalled) { - elements.guildStatusBadge.textContent = 'Bot added'; - elements.guildStatusBadge.className = 'status-badge status-badge--ok'; - elements.guildStatusNote.textContent = 'Settings are active.'; - } else { - elements.guildStatusBadge.textContent = 'Not added'; - elements.guildStatusBadge.className = 'status-badge status-badge--warn'; - elements.guildStatusNote.textContent = 'The bot isn’t in this server yet. Invite it to configure settings.'; - } -}; - -const renderGuilds = () => { - elements.guildList.innerHTML = ''; - if (state.filteredGuilds.length === 0) { - elements.guildEmpty.hidden = false; - return; - } - elements.guildEmpty.hidden = true; - state.filteredGuilds.forEach((guild) => { - const item = document.createElement('button'); - item.type = 'button'; - item.className = `guild-item${state.selectedGuildId === guild.id ? ' active' : ''}`; - item.dataset.guildId = guild.id; - const info = document.createElement('div'); - info.className = 'guild-item__info'; - const icon = document.createElement('img'); - if (guild.icon) { - icon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`; - icon.alt = `${guild.name} icon`; - } else { - icon.alt = ''; - } - const name = document.createElement('span'); - name.textContent = guild.name; - info.appendChild(icon); - info.appendChild(name); - - const badge = document.createElement('span'); - const isBotInGuild = state.botGuildIds.size === 0 ? null : state.botGuildIds.has(guild.id); - badge.className = `status-pill ${isBotInGuild ? 'status-pill--ok' : 'status-pill--warn'}`; - const dot = document.createElement('span'); - dot.className = 'status-pill__dot'; - const text = document.createElement('span'); - text.textContent = isBotInGuild ? 'Bot added' : 'Not added'; - badge.appendChild(dot); - badge.appendChild(text); - - item.appendChild(info); - item.appendChild(badge); - item.addEventListener('click', () => selectGuild(guild.id)); - elements.guildList.appendChild(item); +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 renderRolePicker = (container, roles, selectedIds, onChange, options = {}) => { - container.innerHTML = ''; - const wrapper = document.createElement('div'); - wrapper.className = `multi-picker${options.disabled ? ' multi-picker--disabled' : ''}`; - - const search = document.createElement('input'); - search.type = 'text'; - search.className = 'multi-picker__search'; - search.placeholder = 'Search roles'; - const list = document.createElement('div'); - list.className = 'multi-picker__list'; - const chips = document.createElement('div'); - chips.className = 'chip-list'; - - const renderChips = () => { - chips.innerHTML = ''; - if (selectedIds.length === 0) { - const empty = document.createElement('span'); - empty.className = 'muted'; - empty.textContent = options.emptyMessage || 'No roles selected.'; - chips.appendChild(empty); - return; - } - selectedIds.forEach((roleId) => { - const role = roles.find((entry) => entry.id === roleId); - if (!role) { - return; - } - const chip = document.createElement('span'); - chip.className = 'chip'; - chip.textContent = role.name; - const removeButton = document.createElement('button'); - removeButton.type = 'button'; - removeButton.textContent = '✕'; - removeButton.addEventListener('click', () => { - const next = selectedIds.filter((id) => id !== roleId); - selectedIds.splice(0, selectedIds.length, ...next); - onChange([...selectedIds]); - renderList(search.value); - renderChips(); - }); - chip.appendChild(removeButton); - chips.appendChild(chip); - }); - }; - - const renderList = (query = '') => { - list.innerHTML = ''; - const filteredRoles = roles - .filter((role) => role.name.toLowerCase().includes(query.toLowerCase())) - .sort((a, b) => b.position - a.position); - if (filteredRoles.length === 0) { - const empty = document.createElement('span'); - empty.className = 'muted'; - empty.textContent = 'No matching roles.'; - list.appendChild(empty); - return; - } - filteredRoles.forEach((role) => { - const label = document.createElement('label'); - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.checked = selectedIds.includes(role.id); - checkbox.disabled = options.disabled; - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - if (!selectedIds.includes(role.id)) { - selectedIds.push(role.id); - } - } else { - const next = selectedIds.filter((id) => id !== role.id); - selectedIds.splice(0, selectedIds.length, ...next); - } - onChange([...selectedIds]); - renderChips(); - }); - label.appendChild(checkbox); - label.appendChild(document.createTextNode(role.name)); - list.appendChild(label); - }); - }; - - search.addEventListener('input', () => renderList(search.value)); - - renderList(); - renderChips(); - - wrapper.appendChild(search); - wrapper.appendChild(list); - wrapper.appendChild(chips); - container.appendChild(wrapper); -}; - -const renderAdminUsers = () => { - elements.adminUserChips.innerHTML = ''; - const adminUserIds = state.permissions?.adminUserIds || []; - if (adminUserIds.length === 0) { - const empty = document.createElement('span'); - empty.className = 'muted'; - empty.textContent = 'No admin users added.'; - elements.adminUserChips.appendChild(empty); - return; - } - - adminUserIds.forEach((userId) => { - const chip = document.createElement('span'); - chip.className = 'chip'; - chip.textContent = userId; - const removeButton = document.createElement('button'); - removeButton.type = 'button'; - removeButton.textContent = '✕'; - removeButton.addEventListener('click', () => { - state.permissions.adminUserIds = adminUserIds.filter((id) => id !== userId); - setDirty(true); - renderAdminUsers(); - }); - chip.appendChild(removeButton); - elements.adminUserChips.appendChild(chip); - }); -}; - -const formatMemberDisplay = (member) => { - const user = member?.user || {}; - const displayName = member?.nick || user.global_name || user.username || 'Unknown user'; - const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : ''; - return `${displayName}${tag}`; -}; - -const setSelectedWarningUser = (member) => { - state.selectedWarningUser = member || null; - if (!member) { - elements.warningsSelectedUser.textContent = 'No user selected.'; - return; - } - elements.warningsSelectedUser.textContent = `${formatMemberDisplay(member)} (${member.id})`; - elements.warningsUserInput.value = member.id; - elements.newWarningUserInput.value = member.id; -}; - -const setManualWarningUser = (userId) => { - state.selectedWarningUser = null; - if (!userId) { - elements.warningsSelectedUser.textContent = 'No user selected.'; - return; - } - elements.warningsSelectedUser.textContent = `Using manual ID: ${userId}`; -}; - -const resetWarningSelection = () => { - setSelectedWarningUser(null); - elements.warningsUserResults.innerHTML = ''; - elements.warningsUserSearch.value = ''; - elements.warningsUserInput.value = ''; - elements.newWarningUserInput.value = ''; - elements.warningsList.innerHTML = ''; - elements.warningsEmpty.hidden = true; -}; - -const ensureCommandEntry = (commandName) => { - if (!state.permissions.commandPermissions[commandName]) { - state.permissions.commandPermissions[commandName] = { - allowedRoleIds: [], - allowedUserIds: [], - restricted: false, - }; - } - const entry = state.permissions.commandPermissions[commandName]; - entry.allowedRoleIds = Array.isArray(entry.allowedRoleIds) ? entry.allowedRoleIds : []; - entry.allowedUserIds = Array.isArray(entry.allowedUserIds) ? entry.allowedUserIds : []; - if (typeof entry.restricted !== 'boolean') { - entry.restricted = entry.allowedRoleIds.length > 0 || entry.allowedUserIds.length > 0; - } -}; - -const renderCommands = () => { - elements.commandsList.innerHTML = ''; - const commands = [...new Set(state.commands)].sort(); - if (commands.length === 0) { - elements.commandsEmpty.hidden = false; - return; - } - elements.commandsEmpty.hidden = true; - - commands.forEach((commandName) => { - ensureCommandEntry(commandName); - const commandConfig = state.permissions.commandPermissions[commandName]; - const isRestricted = Boolean(commandConfig.restricted); - - const card = document.createElement('div'); - card.className = `command-card${state.botInstalled ? '' : ' command-card--disabled'}`; - const header = document.createElement('div'); - header.className = 'command-card__header'; - - const title = document.createElement('strong'); - title.textContent = `/${commandName}`; - - const toggleLabel = document.createElement('label'); - toggleLabel.className = 'toggle'; - const toggle = document.createElement('input'); - toggle.type = 'checkbox'; - toggle.checked = isRestricted; - toggleLabel.appendChild(toggle); - toggleLabel.appendChild(document.createTextNode('Restricted')); - - header.appendChild(title); - header.appendChild(toggleLabel); - - const body = document.createElement('div'); - body.className = 'command-card__body'; - - const roleSection = document.createElement('div'); - const roleLabel = document.createElement('p'); - roleLabel.className = 'muted'; - roleLabel.textContent = 'Allowed roles'; - const rolePicker = document.createElement('div'); - roleSection.appendChild(roleLabel); - roleSection.appendChild(rolePicker); - - const userSection = document.createElement('div'); - const userLabel = document.createElement('p'); - userLabel.className = 'muted'; - userLabel.textContent = 'Allowed users'; - const userInput = document.createElement('input'); - userInput.type = 'text'; - userInput.placeholder = 'Paste user IDs'; - const addUserButton = document.createElement('button'); - addUserButton.type = 'button'; - addUserButton.className = 'button'; - addUserButton.textContent = 'Add'; - const userControls = document.createElement('div'); - userControls.className = 'input-row'; - userControls.appendChild(userInput); - userControls.appendChild(addUserButton); - const chipList = document.createElement('div'); - chipList.className = 'chip-list'; - - const renderUserChips = () => { - chipList.innerHTML = ''; - if (commandConfig.allowedUserIds.length === 0) { - const empty = document.createElement('span'); - empty.className = 'muted'; - empty.textContent = 'No users added.'; - chipList.appendChild(empty); - return; - } - commandConfig.allowedUserIds.forEach((userId) => { - const chip = document.createElement('span'); - chip.className = 'chip'; - chip.textContent = userId; - const remove = document.createElement('button'); - remove.type = 'button'; - remove.textContent = '✕'; - remove.addEventListener('click', () => { - commandConfig.allowedUserIds = commandConfig.allowedUserIds.filter((id) => id !== userId); - setDirty(true); - renderUserChips(); - }); - chip.appendChild(remove); - chipList.appendChild(chip); - }); - }; - - const addUsers = () => { - const values = parseIdList(userInput.value); - if (values.length === 0) { - return; - } - const merged = Array.from(new Set([...commandConfig.allowedUserIds, ...values])); - commandConfig.allowedUserIds = merged; - setDirty(true); - userInput.value = ''; - renderUserChips(); - }; - - addUserButton.addEventListener('click', addUsers); - userInput.addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - event.preventDefault(); - addUsers(); - } - }); - - userSection.appendChild(userLabel); - userSection.appendChild(userControls); - userSection.appendChild(chipList); - renderUserChips(); - - const actions = document.createElement('div'); - const clearButton = document.createElement('button'); - clearButton.type = 'button'; - clearButton.className = 'button button--ghost'; - clearButton.textContent = 'Clear'; - clearButton.addEventListener('click', () => { - commandConfig.allowedRoleIds = []; - commandConfig.allowedUserIds = []; - commandConfig.restricted = false; - setDirty(true); - renderCommands(); - }); - actions.appendChild(clearButton); - - body.appendChild(roleSection); - body.appendChild(userSection); - body.appendChild(actions); - - toggle.addEventListener('change', () => { - setDirty(true); - commandConfig.restricted = toggle.checked; - if (!toggle.checked) { - commandConfig.allowedRoleIds = []; - commandConfig.allowedUserIds = []; - } - renderCommands(); - }); - - const controlsDisabled = !state.botInstalled || !isRestricted; - userInput.disabled = controlsDisabled; - addUserButton.disabled = controlsDisabled; - - renderRolePicker( - rolePicker, - state.roles, - commandConfig.allowedRoleIds, - (selected) => { - commandConfig.allowedRoleIds = selected; - setDirty(true); - }, - { - disabled: !state.botInstalled || !isRestricted, - emptyMessage: isRestricted ? 'Select roles to allow.' : 'Allow all roles.', - }, - ); - - card.appendChild(header); - card.appendChild(body); - elements.commandsList.appendChild(card); - }); -}; - -const renderPanels = () => { - renderRolePicker( - elements.adminRolesPicker, - state.roles, - state.permissions.adminRoleIds, - (selected) => { - state.permissions.adminRoleIds = selected; - setDirty(true); - }, - { - disabled: !state.botInstalled, - emptyMessage: state.botInstalled ? 'Select roles to grant admin access.' : 'Invite bot to load roles.', - }, +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), + ), ); - renderAdminUsers(); - renderCommands(); - - if (!state.botInstalled) { - elements.settingsPanel.classList.add('panel--disabled'); - elements.permissionsPanel.classList.add('panel--disabled'); - elements.botInactiveCallout.hidden = false; - } else { - elements.settingsPanel.classList.remove('panel--disabled'); - elements.permissionsPanel.classList.remove('panel--disabled'); - elements.botInactiveCallout.hidden = true; - } }; - -const updateBotStatusControls = () => { - if (state.botInstalled) { - elements.inviteButton.hidden = true; - elements.refreshBotButton.hidden = true; - } else { - elements.inviteButton.hidden = false; - elements.refreshBotButton.hidden = false; - } +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 loadSession = async () => { - const data = await fetchDashboardJson('/api/me'); - elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user); - dashboardApi.setCsrfToken(data.csrfToken); - if (data.expiresAt) { - const expires = new Date(data.expiresAt); - elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`; - } +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 loadGuilds = async () => { - setLoadingGuilds(true); - try { - const data = await fetchDashboardJson('/api/guilds'); - state.guilds = data.guilds || []; - state.filteredGuilds = [...state.guilds]; - if (state.guilds.length > 0 && !state.selectedGuildId) { - state.selectedGuildId = state.guilds[0].id; - } - renderGuilds(); - } finally { - setLoadingGuilds(false); - } +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 fetchDashboardJson('/api/bot/guilds'); - state.botGuildIds = new Set(data.guildIds || []); - } catch (error) { - state.botGuildIds = new Set(); - handleFetchError(error, 'Failed to fetch bot status.'); - } + 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 updateBotInstalled = () => { - state.botInstalled = state.selectedGuildId ? state.botGuildIds.has(state.selectedGuildId) : false; +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) { - return; - } - setLoadingDetails(true); - setError(null); - setDirty(false); - resetWarningSelection(); - - const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId); - updateBotInstalled(); - updateGuildHeader(guild); - updateBotStatusControls(); - - try { - const [permissionsData, commandsData] = await Promise.all([ - fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`), - fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/commands`), - ]); - - state.permissions = normalizePermissions(permissionsData.permissions || {}); - state.permissions.commandPermissions = state.permissions.commandPermissions || {}; - state.commands = commandsData.commands || []; - - Object.keys(state.permissions.commandPermissions).forEach((commandName) => { - if (!state.commands.includes(commandName)) { - state.commands.push(commandName); - } - }); - - if (state.botInstalled) { - try { - const rolesData = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/roles`); - state.roles = rolesData.roles || []; - } catch (error) { - if (error?.code === 'BOT_NOT_IN_GUILD') { - state.botInstalled = false; - state.roles = []; - } else { - handleFetchError(error, 'Failed to fetch roles.'); - } - } - } else { - state.roles = []; - } - - updateGuildHeader(guild); - updateBotStatusControls(); - state.originalPermissions = clonePermissions(state.permissions); - renderPanels(); - const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'settings'; - switchTab(activeTab); - } catch (error) { - handleFetchError(error, 'Failed to load guild data.'); - } finally { - setLoadingDetails(false); + 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 (guildId) => { - if (state.dirty) { - const proceed = window.confirm('You have unsaved changes. Discard them and switch guilds?'); - if (!proceed) { - return; - } +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(); + } } - state.selectedGuildId = guildId; + } 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(); + } }; - -const addAdminUser = () => { - const values = parseIdList(elements.adminUserInput.value); - if (values.length === 0) { - return; - } - state.permissions.adminUserIds = Array.from(new Set([...state.permissions.adminUserIds, ...values])); - elements.adminUserInput.value = ''; - setDirty(true); - renderAdminUsers(); -}; - -const addCommand = () => { - const value = elements.newCommandInput.value.trim(); - if (!value) { - return; - } - if (!COMMAND_NAME_REGEX.test(value)) { - setError({message: 'Command name must be 1-32 characters and use letters, numbers, - or _.'}); - return; - } - if (!state.commands.includes(value)) { - state.commands.push(value); - setDirty(true); - } - elements.newCommandInput.value = ''; - renderCommands(); -}; - -const savePermissions = async () => { - if (!state.selectedGuildId) { - return; - } - - setLoadingDetails(true); - try { - const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`, { - method: 'PATCH', - body: JSON.stringify(state.permissions), - }); - state.permissions = normalizePermissions(data.permissions || {}); - state.originalPermissions = clonePermissions(state.permissions); - setDirty(false); - dashboardApi.showToast('Permissions saved.', 'success'); - renderPanels(); - } catch (error) { - handleFetchError(error, 'Failed to save permissions.'); - } finally { - setLoadingDetails(false); - } -}; - -const discardChanges = () => { - if (!state.originalPermissions) { - return; - } - state.permissions = clonePermissions(state.originalPermissions); - setDirty(false); - renderPanels(); -}; - -const switchTab = (tabName) => { - elements.tabs.forEach((tab) => { - tab.classList.toggle('active', tab.dataset.tab === tabName); - }); - elements.settingsPanel.hidden = tabName !== 'settings'; - elements.permissionsPanel.hidden = tabName !== 'permissions'; - elements.warningsPanel.hidden = tabName !== 'warnings'; -}; - -const resolveWarningUserId = (value) => { - if (value && value.trim()) { - return value.trim(); - } - return state.selectedWarningUser?.id || ''; -}; - -const renderWarningMembers = (members = []) => { - elements.warningsUserResults.innerHTML = ''; - if (!members.length) { - const empty = document.createElement('span'); - empty.className = 'muted'; - empty.textContent = 'No members found.'; - elements.warningsUserResults.appendChild(empty); - return; - } - - members.forEach((member) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = `member-item${state.selectedWarningUser?.id === member.id ? ' active' : ''}`; - const meta = document.createElement('div'); - meta.className = 'member-item__meta'; - const name = document.createElement('span'); - name.className = 'member-item__name'; - name.textContent = formatMemberDisplay(member); - const id = document.createElement('span'); - id.className = 'member-item__id'; - id.textContent = member.id; - meta.appendChild(name); - meta.appendChild(id); - button.appendChild(meta); - button.addEventListener('click', () => { - setSelectedWarningUser(member); - renderWarningMembers(members); - }); - elements.warningsUserResults.appendChild(button); - }); -}; - -const searchWarningMembers = async (query) => { - if (!state.selectedGuildId || !query || query.trim().length < 2) { - elements.warningsUserResults.innerHTML = ''; - return; - } - try { - const data = await fetchDashboardJson( - `/api/guilds/${state.selectedGuildId}/members?query=${encodeURIComponent(query.trim())}`, - ); - const members = data.members || []; - renderWarningMembers(members); - } catch (error) { - handleFetchError(error, 'Failed to search members.'); - } -}; - -const fetchWarnings = async () => { - const userId = resolveWarningUserId(elements.warningsUserInput.value); - if (!userId) { - setError({message: 'Enter a user ID to fetch warnings.'}); - return; - } - elements.warningsSpinner.hidden = false; - try { - const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`); - const warnings = data.warnings || []; - elements.warningsList.innerHTML = ''; - if (warnings.length === 0) { - elements.warningsEmpty.hidden = false; - } else { - elements.warningsEmpty.hidden = true; - warnings.forEach((warning) => { - const item = document.createElement('li'); - item.className = 'warning-item'; - const text = document.createElement('p'); - text.textContent = warning.reason; - const meta = document.createElement('div'); - meta.className = 'warning-item__meta'; - const dateText = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date'; - const moderator = document.createElement('span'); - moderator.textContent = `Moderator: ${warning.moderatorId || 'Unknown'}`; - const copyButton = document.createElement('button'); - copyButton.type = 'button'; - copyButton.className = 'button button--ghost'; - copyButton.textContent = 'Copy moderator ID'; - copyButton.addEventListener('click', async () => { - if (!warning.moderatorId) { - return; - } - try { - await navigator.clipboard.writeText(warning.moderatorId); - dashboardApi.showToast('Moderator ID copied.', 'success'); - } catch (error) { - dashboardApi.showToast('Unable to copy moderator ID.', 'error'); - } - }); - meta.appendChild(moderator); - meta.appendChild(document.createElement('span')).textContent = dateText; - item.appendChild(text); - item.appendChild(meta); - item.appendChild(copyButton); - elements.warningsList.appendChild(item); - }); - } - setError(null); - } catch (error) { - handleFetchError(error, 'Failed to fetch warnings.'); - } finally { - elements.warningsSpinner.hidden = true; - } -}; - -const updateWarningCount = () => { - const length = elements.warningReasonInput.value.length; - elements.warningCharCount.textContent = `${length}/200`; -}; - -const addWarning = async () => { - const userId = resolveWarningUserId(elements.newWarningUserInput.value); - const reason = elements.warningReasonInput.value.trim(); - if (!userId || !reason) { - elements.warningStatus.textContent = 'User ID and reason are required.'; - return; - } - if (reason.length > 200) { - elements.warningStatus.textContent = 'Reason must be under 200 characters.'; - return; - } - - elements.warningStatus.textContent = ''; - try { - const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, { - method: 'POST', - body: JSON.stringify({reason}), - }); - elements.warningStatus.textContent = `Warning added. Total warnings: ${data.warnings?.length || 0}`; - elements.warningReasonInput.value = ''; - updateWarningCount(); - dashboardApi.showToast('Warning saved.', 'success'); - } catch (error) { - handleFetchError(error, 'Failed to add warning.'); - } -}; - -const clearWarnings = async () => { - const userId = resolveWarningUserId(elements.warningsUserInput.value); - if (!userId) { - setError({message: 'Select a user to clear warnings.'}); - return; - } - const proceed = window.confirm('Clear all warnings for this user?'); - if (!proceed) { - return; - } - try { - await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, { - method: 'DELETE', - }); - elements.warningsList.innerHTML = ''; - elements.warningsEmpty.hidden = false; - elements.warningStatus.textContent = 'Warnings cleared.'; - dashboardApi.showToast('Warnings cleared.', 'success'); - } catch (error) { - handleFetchError(error, 'Failed to clear warnings.'); - } -}; - -const refreshBotStatus = async () => { - await loadBotGuilds(); - updateBotInstalled(); - const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId); - updateGuildHeader(guild); - updateBotStatusControls(); - renderPanels(); -}; - -const openInvite = async () => { - try { - const data = await fetchDashboardJson(`/api/invite-url?guildId=${state.selectedGuildId}`); - if (data.inviteUrl) { - window.open(data.inviteUrl, '_blank', 'noopener,noreferrer'); - } - } catch (error) { - handleFetchError(error, 'Failed to generate invite URL.'); - } -}; - -const openCopyModal = () => { - elements.copyGuildSelect.innerHTML = ''; - state.guilds - .filter((guild) => guild.id !== state.selectedGuildId) - .forEach((guild) => { - const option = document.createElement('option'); - option.value = guild.id; - option.textContent = guild.name; - elements.copyGuildSelect.appendChild(option); - }); - elements.copyModal.hidden = false; -}; - -const closeCopyModal = () => { - elements.copyModal.hidden = true; -}; - -const confirmCopy = async () => { - const guildId = elements.copyGuildSelect.value; - if (!guildId) { - closeCopyModal(); - return; - } - try { - const data = await fetchDashboardJson(`/api/guilds/${guildId}/permissions`); - state.permissions = normalizePermissions(data.permissions || {}); - setDirty(true); - renderPanels(); - dashboardApi.showToast('Permissions copied. Review before saving.', 'success'); - } catch (error) { - handleFetchError(error, 'Failed to copy permissions.'); - } finally { - closeCopyModal(); - } -}; - -const initDashboard = async () => { - try { - await loadSession(); - await Promise.all([loadGuilds(), loadBotGuilds()]); - renderGuilds(); - if (state.selectedGuildId) { - await loadGuildDetails(); - } - } catch (error) { - setError({message: 'Please log in to access the dashboard.'}); - window.location.href = '/'; - return; - } - - elements.logoutButton.addEventListener('click', async () => { - await fetch('/auth/logout', { - method: 'POST', - credentials: 'include', - headers: { - ...(dashboardApi.getCsrfToken() ? {'X-CSRF-Token': dashboardApi.getCsrfToken()} : {}), - }, - }); - window.location.href = '/'; - }); - - elements.tabs.forEach((tab) => { - tab.addEventListener('click', () => switchTab(tab.dataset.tab)); - }); - - elements.addAdminUserButton.addEventListener('click', addAdminUser); - elements.adminUserInput.addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - event.preventDefault(); - addAdminUser(); - } - }); - elements.addCommandButton.addEventListener('click', addCommand); - elements.savePermissionsButton.addEventListener('click', savePermissions); - elements.discardChangesButton.addEventListener('click', discardChanges); - elements.fetchWarningsButton.addEventListener('click', fetchWarnings); - elements.clearWarningsButton.addEventListener('click', clearWarnings); - elements.addWarningButton.addEventListener('click', addWarning); - elements.warningReasonInput.addEventListener('input', updateWarningCount); - elements.warningsUserSearch.addEventListener( - 'input', - dashboardApi.debounce((event) => { - searchWarningMembers(event.target.value); - }, 250), - ); - elements.warningsUserInput.addEventListener('input', (event) => { - setManualWarningUser(event.target.value.trim()); - }); - elements.newWarningUserInput.addEventListener('input', (event) => { - if (event.target.value.trim()) { - setManualWarningUser(event.target.value.trim()); - } - }); - elements.inviteButton.addEventListener('click', openInvite); - elements.refreshBotButton.addEventListener('click', refreshBotStatus); - elements.clearAllCommands.addEventListener('click', () => { - Object.keys(state.permissions.commandPermissions).forEach((commandName) => { - state.permissions.commandPermissions[commandName] = { - allowedRoleIds: [], - allowedUserIds: [], - restricted: false, - }; - }); - setDirty(true); - renderCommands(); - }); - elements.copyFromGuild.addEventListener('click', openCopyModal); - elements.cancelCopyButton.addEventListener('click', closeCopyModal); - elements.confirmCopyButton.addEventListener('click', confirmCopy); - updateWarningCount(); - - elements.guildSearch.addEventListener( - 'input', - dashboardApi.debounce((event) => { - const query = event.target.value.toLowerCase(); - state.filteredGuilds = state.guilds.filter((guild) => guild.name.toLowerCase().includes(query)); - renderGuilds(); - }, 200), - ); - - window.addEventListener('beforeunload', (event) => { - if (state.dirty) { - event.preventDefault(); - event.returnValue = ''; - } - }); -}; - -document.addEventListener('DOMContentLoaded', () => { - initDashboard(); -}); +document.addEventListener("DOMContentLoaded", initDashboard); diff --git a/public/icons.png b/public/icons.png new file mode 100644 index 0000000..b3687fe Binary files /dev/null and b/public/icons.png differ diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..b9ce855 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html index c372e8e..0ad506a 100644 --- a/public/index.html +++ b/public/index.html @@ -1,36 +1,53 @@ - - - - Discord Bot Dashboard - - - -
-
-
-
Discord Bot Dashboard
-

Manage your moderation settings

-

Configure permissions and warnings without touching JSON.

-
- -
- - - -
-

Session status

-

Checking your session...

-
- -
-
+ + + + + The Steward + + + + +
+
+ THESteward. +
+
+
+

Welcome.

+

Manage your Discord servers.

+ +
+ Checking session… +
+
- - - +
+ Brass service bell +
+
+
+ + diff --git a/public/mark.png b/public/mark.png new file mode 100644 index 0000000..926e6df Binary files /dev/null and b/public/mark.png differ diff --git a/public/mark.svg b/public/mark.svg new file mode 100644 index 0000000..00efe19 --- /dev/null +++ b/public/mark.svg @@ -0,0 +1 @@ + diff --git a/public/styles.css b/public/styles.css index d2a9f18..c9b6790 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,1001 +1,1837 @@ :root { - color-scheme: light; - --bg: #0b1113; - --bg-accent: #122025; - --panel: #101820; - --card: #151f27; - --border: #24313b; - --text: #f4f7fb; - --muted: #9fb0bc; - --primary: #2cb9c6; - --primary-dark: #2197a2; - --success: #33d17a; - --danger: #ff6b6b; - --warning: #f4b860; - --glow: rgba(44, 185, 198, 0.25); - font-family: 'Space Grotesk', 'Satoshi', 'Segoe UI', sans-serif; + color-scheme: light; + --bg: #f7f5ef; + --panel: #fffefa; + --ink: #342a2a; + --muted: #77736b; + --line: #e5e0d5; + --wine: #432c30; + --wine-dark: #302224; + --gold: #b99a64; + --serif: Georgia, "Times New Roman", serif; + font-family: + "Segoe UI", + -apple-system, + BlinkMacSystemFont, + sans-serif; + font-size: 13px; + color: var(--ink); + background: var(--bg); + font-synthesis: none; } - * { - box-sizing: border-box; + box-sizing: border-box; } - [hidden] { - display: none !important; + display: none !important; } - body { - margin: 0; - min-height: 100vh; - background: radial-gradient(1200px 800px at 15% 10%, rgba(44, 185, 198, 0.12), transparent 60%), - radial-gradient(900px 700px at 85% 0%, rgba(244, 184, 96, 0.12), transparent 55%), - linear-gradient(180deg, #0b1113 0%, #0e1418 55%, #0b1113 100%); - color: var(--text); + margin: 0; + min-width: 320px; } - -.app { - max-width: 960px; - margin: 0 auto; - padding: 48px 24px 96px; - position: relative; -} - -.topbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 24px; - padding: 28px; - background: linear-gradient(135deg, rgba(44, 185, 198, 0.08), rgba(16, 24, 32, 0.9)); - border-radius: 22px; - border: 1px solid rgba(44, 185, 198, 0.35); - margin-bottom: 28px; - box-shadow: 0 20px 40px rgba(5, 8, 10, 0.45); - animation: rise 0.45s ease; -} - -.badge { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 999px; - font-size: 0.85rem; - background: rgba(44, 185, 198, 0.15); - border: 1px solid rgba(44, 185, 198, 0.4); - color: #d7fbff; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.banner { - padding: 14px 18px; - border-radius: 14px; - border: 1px solid rgba(244, 184, 96, 0.35); - background: rgba(244, 184, 96, 0.12); - margin-bottom: 20px; - color: #ffe3b0; - display: none; -} - -.muted { - color: var(--muted); -} - -a { - color: inherit; - text-decoration: none; -} - -button { - font: inherit; -} - +button, input, select, textarea { - font: inherit; + font: inherit; +} +button, +a { + touch-action: manipulation; +} +button { + cursor: pointer; + color: inherit; +} +a { + color: inherit; + text-decoration: none; +} +svg { + width: 20px; + height: 20px; + flex-shrink: 0; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; } - -.app-shell { - max-width: 1280px; - margin: 0 auto; - padding: 24px 24px 60px; - position: relative; - animation: rise 0.45s ease; -} - -.navbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - padding: 16px 22px; - background: rgba(16, 24, 32, 0.9); - border-radius: 18px; - border: 1px solid var(--border); - margin-bottom: 22px; - box-shadow: 0 14px 30px rgba(5, 8, 10, 0.4); -} - -.navbar__brand { - display: flex; - align-items: center; - gap: 12px; -} - -.brand-mark { - width: 44px; - height: 44px; - border-radius: 14px; - background: linear-gradient(135deg, var(--primary), #58d6c7); - display: grid; - place-items: center; - font-weight: 700; - color: #031417; - text-shadow: none; -} - -.navbar__actions { - display: flex; - gap: 12px; - align-items: center; -} - -.error-banner { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - padding: 14px 18px; - border-radius: 12px; - border: 1px solid rgba(255, 107, 107, 0.45); - background: rgba(255, 107, 107, 0.12); - margin-bottom: 16px; -} - -.toast-container { - position: fixed; - top: 20px; - right: 24px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 50; -} - -.toast { - opacity: 0; - transform: translateY(-8px); - padding: 12px 16px; - border-radius: 12px; - background: rgba(16, 24, 32, 0.92); - border: 1px solid var(--border); - transition: all 0.2s ease; -} - -.toast--visible { - opacity: 1; - transform: translateY(0); -} - -.toast--success { - border-color: rgba(59, 165, 93, 0.5); -} - -.toast--error { - border-color: rgba(237, 66, 69, 0.5); -} - -.layout { - display: grid; - grid-template-columns: 280px 1fr; - gap: 24px; -} - -.sidebar { - background: rgba(16, 24, 32, 0.9); - border-radius: 18px; - border: 1px solid var(--border); - padding: 16px; - box-shadow: inset 0 0 0 1px rgba(44, 185, 198, 0.08); - animation: rise 0.45s ease; -} - -.sidebar__header { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 16px; -} - -.sidebar input[type='search'] { - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--border); - background: #111a22; - color: var(--text); -} - -.guild-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.guild-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - background: rgba(21, 31, 39, 0.9); - border-radius: 12px; - border: 1px solid transparent; - cursor: pointer; - transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease; -} - -.guild-item__info { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} - -.guild-item img { - width: 36px; - height: 36px; - border-radius: 12px; - background: var(--border); -} - -.guild-item span { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.guild-item.active { - border-color: var(--primary); -} - -.guild-item:hover { - border-color: rgba(44, 185, 198, 0.5); - transform: translateY(-1px); -} - -.status-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - font-size: 0.75rem; - border: 1px solid var(--border); -} - -.status-pill__dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--muted); -} - -.status-pill--ok { - border-color: rgba(59, 165, 93, 0.5); - color: #d6f5de; -} - -.status-pill--ok .status-pill__dot { - background: var(--success); -} - -.status-pill--warn { - border-color: rgba(246, 195, 67, 0.5); - color: #ffe8b1; -} - -.status-pill--warn .status-pill__dot { - background: var(--warning); -} - -.content { - display: flex; - flex-direction: column; - gap: 16px; -} - -.guild-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - padding: 18px; - background: rgba(16, 24, 32, 0.9); - border-radius: 18px; - border: 1px solid var(--border); - box-shadow: inset 0 0 0 1px rgba(244, 184, 96, 0.06); - animation: rise 0.45s ease; -} - -.guild-header__info { - display: flex; - align-items: center; - gap: 16px; -} - -.guild-header__info img { - width: 56px; - height: 56px; - border-radius: 16px; - background: var(--border); -} - -.guild-header__status { - display: flex; - align-items: center; - gap: 10px; - margin-top: 6px; -} - -.status-badge { - padding: 4px 10px; - border-radius: 999px; - border: 1px solid var(--border); - font-size: 0.8rem; -} - -.status-badge--ok { - border-color: rgba(59, 165, 93, 0.5); - color: #d6f5de; -} - -.status-badge--warn { - border-color: rgba(246, 195, 67, 0.5); - color: #ffe8b1; -} - -.tabs { - display: inline-flex; - gap: 8px; - padding: 6px; - background: rgba(16, 24, 32, 0.9); - border-radius: 14px; - border: 1px solid var(--border); - width: fit-content; -} - -.tab { - padding: 8px 16px; - border-radius: 8px; - border: none; - background: transparent; - color: var(--muted); - cursor: pointer; - transition: all 0.2s ease; -} - -.tab.active { - background: linear-gradient(135deg, var(--primary), #58d6c7); - color: #041316; - box-shadow: 0 10px 18px rgba(44, 185, 198, 0.25); -} - -.panel { - background: rgba(16, 24, 32, 0.9); - border-radius: 18px; - border: 1px solid var(--border); - padding: 18px; - box-shadow: 0 18px 30px rgba(5, 8, 10, 0.35); - animation: rise 0.45s ease; -} - -.panel__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; -} - -.panel__header--split { - align-items: flex-start; -} - -.callout { - padding: 12px; - border-radius: 12px; - border: 1px solid rgba(244, 184, 96, 0.5); - background: rgba(244, 184, 96, 0.14); - margin-bottom: 16px; -} - -.field { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 16px; -} - -.field label { - font-size: 0.9rem; - color: var(--muted); -} - -.field input, -.field select, -.field textarea { - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--border); - background: #111a22; - color: var(--text); -} - -.field textarea { - min-height: 100px; - resize: vertical; -} - -.field__meta { - display: flex; - justify-content: space-between; - align-items: center; -} - -.input-row { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.multi-picker { - background: #111a22; - border-radius: 12px; - border: 1px solid var(--border); - padding: 12px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.multi-picker__search { - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--border); - background: #0f1820; - color: var(--text); -} - -.multi-picker__list { - max-height: 180px; - overflow-y: auto; - display: grid; - gap: 8px; -} - -.multi-picker__list label { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.9rem; -} - -.chip-list { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-radius: 999px; - background: var(--card); - border: 1px solid var(--border); - font-size: 0.85rem; -} - -.chip button { - border: none; - background: transparent; - color: var(--muted); - cursor: pointer; -} - -.button { - padding: 10px 16px; - border-radius: 10px; - border: 1px solid var(--border); - background: rgba(21, 31, 39, 0.9); - color: var(--text); - cursor: pointer; - transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease; -} - -.button--primary { - background: linear-gradient(135deg, var(--primary), #58d6c7); - border-color: var(--primary); - color: #041316; - box-shadow: 0 10px 18px rgba(44, 185, 198, 0.3); -} - -.button--primary:hover { - background: linear-gradient(135deg, var(--primary-dark), #3cc0b1); - transform: translateY(-1px); -} - -.button--ghost { - background: transparent; -} - -.button:hover { - border-color: rgba(44, 185, 198, 0.35); - transform: translateY(-1px); -} - -.button:focus-visible, -input:focus-visible, -select:focus-visible, -textarea:focus-visible, -.tab:focus-visible { - outline: 2px solid rgba(44, 185, 198, 0.6); - outline-offset: 2px; -} - -.button-row { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.toolbar { - display: grid; - grid-template-columns: minmax(240px, 1fr) 220px; - gap: 14px; - margin-bottom: 18px; -} - -.toolbar__field { - margin-bottom: 0; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); - gap: 12px; - margin-bottom: 18px; -} - -.stats-grid--compact { - margin-top: 4px; - margin-bottom: 16px; -} - -.stat-card { - padding: 14px; - border-radius: 14px; - border: 1px solid var(--border); - background: linear-gradient(180deg, rgba(21, 31, 39, 0.96), rgba(17, 26, 34, 0.9)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); -} - -.stat-card--compact { - padding: 12px; -} - -.stat-card__label { - display: block; - margin-bottom: 8px; - color: var(--muted); - font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.stat-card__value { - display: block; - font-size: 1.8rem; - line-height: 1.1; - margin-bottom: 8px; -} - -.stat-card__value--small { - font-size: 1rem; -} - -.command-list { - display: flex; - flex-direction: column; - gap: 16px; -} - -.command-card { - background: rgba(21, 31, 39, 0.95); - border-radius: 16px; - border: 1px solid var(--border); - padding: 14px; - transition: border-color 0.2s ease, transform 0.2s ease; -} - -.command-card:hover { - border-color: rgba(44, 185, 198, 0.4); - transform: translateY(-1px); -} - -.command-card__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.command-card__title { - display: flex; - flex-direction: column; - gap: 6px; -} - -.command-card__summary { - display: inline-flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.mini-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 9px; - border-radius: 999px; - border: 1px solid var(--border); - color: var(--muted); - font-size: 0.76rem; - background: rgba(11, 17, 19, 0.25); -} - -.mini-badge--accent { - border-color: rgba(44, 185, 198, 0.45); - color: #ccf7fb; -} - -.mini-badge--warn { - border-color: rgba(244, 184, 96, 0.45); - color: #ffe8b1; -} - -.mini-badge--ok { - border-color: rgba(51, 209, 122, 0.45); - color: #d6f5de; -} - -.command-card__body { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 16px; - margin-top: 12px; -} - -.member-list { - display: flex; - flex-direction: column; - gap: 8px; - max-height: 220px; - overflow-y: auto; - padding: 10px; - border-radius: 12px; - border: 1px solid var(--border); - background: #111a22; -} - -.member-item { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid transparent; - background: var(--card); - cursor: pointer; -} - -.member-item__meta { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.member-item__name { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.member-item__id { - font-size: 0.8rem; - color: var(--muted); -} - -.member-item.active { - border-color: var(--primary); -} - -.toggle { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.toggle input { - width: 40px; - height: 20px; - appearance: none; - background: var(--border); - border-radius: 999px; - position: relative; - cursor: pointer; -} - -.toggle input::after { - content: ''; - position: absolute; - width: 16px; - height: 16px; - border-radius: 999px; - background: white; - top: 2px; - left: 2px; - transition: transform 0.2s ease; -} - -.toggle input:checked { - background: var(--primary); -} - -.toggle input:checked::after { - transform: translateX(20px); -} - -.warning-item { - background: rgba(21, 31, 39, 0.9); - border-radius: 12px; - border: 1px solid var(--border); - padding: 10px; - margin-bottom: 10px; - list-style: none; -} - -.warning-item__meta { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - font-size: 0.85rem; - color: var(--muted); -} - -.warnings-layout { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 16px; -} - -.warnings-pane { - background: rgba(21, 31, 39, 0.9); - border-radius: 12px; - border: 1px solid var(--border); - padding: 14px; -} - -.selected-user-card { - display: flex; - flex-direction: column; - gap: 8px; - padding: 14px; - border-radius: 14px; - border: 1px solid rgba(44, 185, 198, 0.25); - background: linear-gradient(180deg, rgba(44, 185, 198, 0.08), rgba(17, 26, 34, 0.9)); - margin-bottom: 16px; -} - -.selected-user-card__label { - color: var(--muted); - font-size: 0.78rem; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.warning-status-pill { - display: inline-flex; - align-items: center; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--border); - color: var(--muted); - background: rgba(17, 26, 34, 0.8); - font-size: 0.8rem; -} - -.divider { - height: 1px; - background: var(--border); - margin: 16px 0; -} - -.save-bar { - position: sticky; - bottom: 16px; - background: rgba(16, 24, 32, 0.95); - border-radius: 18px; - border: 1px solid var(--border); - padding: 12px 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - box-shadow: 0 16px 30px rgba(5, 8, 10, 0.4); -} - -.save-indicator { - display: inline-flex; - align-items: center; - gap: 8px; - font-weight: 600; -} - -.save-indicator::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--warning); -} - -.empty-state { - padding: 20px; - border-radius: 12px; - border: 1px dashed var(--border); - background: #111a22; - color: var(--muted); -} - -.spinner { - width: 28px; - height: 28px; - border-radius: 999px; - border: 3px solid rgba(255, 255, 255, 0.2); - border-top-color: var(--primary); - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.skeleton-list, -.skeleton-panel { - display: grid; - gap: 12px; -} - -.skeleton-line { - height: 14px; - border-radius: 999px; - background: linear-gradient(90deg, #16222a 25%, #1d2b34 37%, #16222a 63%); - background-size: 400% 100%; - animation: shimmer 1.4s ease infinite; -} - -@keyframes shimmer { - 0% { - background-position: 100% 0; - } - 100% { - background-position: 0 0; - } -} - -.tooltip { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 999px; - background: var(--border); - color: var(--muted); - font-size: 0.75rem; - margin-left: 6px; - cursor: help; -} - -.modal { - position: fixed; - inset: 0; - background: rgba(10, 12, 18, 0.7); - display: grid; - place-items: center; - z-index: 40; - backdrop-filter: blur(6px); -} - -.modal[hidden] { - display: none; -} - -.modal__content { - background: rgba(16, 24, 32, 0.98); - border-radius: 18px; - border: 1px solid var(--border); - padding: 20px; - width: min(420px, 90vw); - display: flex; - flex-direction: column; - gap: 16px; - box-shadow: 0 24px 40px rgba(5, 8, 10, 0.6); -} - -.modal__content select { - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--border); - background: #111a22; - color: var(--text); -} - -.command-card--disabled, -.panel--disabled, -.multi-picker--disabled { - opacity: 0.6; - pointer-events: none; -} - h1, h2, h3, -strong { - font-family: 'Fraunces', 'Space Grotesk', 'Segoe UI', sans-serif; - letter-spacing: 0.01em; +p { + margin: 0; +} +h1, +h2, +h3 { + font-family: var(--serif); + font-weight: 400; +} +h2 { + font-size: 24px; + line-height: 1.25; +} +h3 { + font-size: 22px; +} +p { + line-height: 1.7; +} +button:disabled { + opacity: 0.46; + cursor: not-allowed; +} +:focus-visible { + outline: 2px solid #a17c3e; + outline-offset: 4px; +} +::selection { + background: #ddcda9; + color: var(--wine-dark); +} +.muted, +.field-hint { + color: var(--muted); +} +.field-hint { + font-size: 12px; + line-height: 1.6; +} +.skip-link { + position: fixed; + top: -70px; + left: 16px; + z-index: 100; + padding: 12px 18px; + background: var(--panel); + border: 1px solid var(--gold); +} +.skip-link:focus { + top: 12px; +} +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 248px minmax(0, 1fr); +} +.sidebar { + background: var(--wine-dark); + color: #d8c9c0; + padding: 34px 20px 0; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #75615b transparent; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + width: fit-content; +} +.brand img { + width: 43px; + height: 47px; + border-radius: 3px; +} +.brand > span { + font-family: var(--serif); + font-size: 31px; + letter-spacing: -0.7px; + line-height: 1; + color: #f7efe3; +} +.brand small { + display: block; + font-family: "Segoe UI", sans-serif; + font-size: 8px; + font-weight: 400; + letter-spacing: 3.6px; + margin: 0 0 5px 2px; +} +.brand-period { + color: var(--gold); +} +.sidebar__section-label { + font-size: 9px; + letter-spacing: 1.6px; + color: #b7a295; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px; + margin-bottom: 14px; +} +.sidebar__section-label > span { + font-family: var(--serif); + font-size: 11px; + letter-spacing: 0; +} +.tabs { + display: grid; + gap: 5px; +} +.tab { + position: relative; + width: 100%; + border: 1px solid transparent; + border-radius: 4px; + padding: 13px 11px; + display: flex; + align-items: center; + gap: 12px; + background: none; + color: #c9bbb1; + font-size: 12px; + text-align: left; + white-space: nowrap; +} +.tab svg { + width: 17px; + height: 17px; + color: #bfa786; +} +.tab:hover { + background: #ffffff08; +} +.tab.active { + color: #f8efdf; + background: #54403a; + border-color: #81684866; +} +.tab.active::before { + content: ""; + position: absolute; + left: -21px; + top: 10px; + bottom: 10px; + width: 3px; + background: var(--gold); +} +.nav-arrow, +.nav-count { + margin-left: auto; + color: #d7be94; +} +.nav-count { + font-size: 9px; + min-width: 19px; + padding: 3px; + border: 1px solid #b99a643d; + text-align: center; + border-radius: 3px; +} +.server-label { + margin-top: 37px; +} +.search-field { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--panel); + padding: 0 12px; +} +.search-field svg { + color: #8f8a7d; + width: 16px; + height: 16px; +} +.search-field input { + border: 0; + padding-left: 0; + background: transparent; + width: 100%; + min-width: 0; + outline-offset: 1px; +} +.search-field:focus-within { + border-color: #b99a64; +} +.sidebar-search { + background: #ffffff04; + border-color: #a990752e; + margin-bottom: 12px; +} +.sidebar-search input { + color: #eee4d6; + font-size: 11px; +} +.sidebar-search input::placeholder { + color: #b7a497; +} +.guild-list { + display: grid; + gap: 4px; +} +.guild-item { + border: 1px solid transparent; + background: transparent; + width: 100%; + display: flex; + text-align: left; + align-items: center; + gap: 9px; + padding: 9px; + border-radius: 4px; + color: #cdbfb4; +} +.guild-item:hover, +.guild-item.active { + background: #ffffff07; + border-color: #bda58226; +} +.guild-item.active { + color: #f3e6d3; +} +.guild-item__info { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; +} +.guild-item__name { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.guild-avatar { + width: 32px; + height: 32px; + display: grid; + place-items: center; + background: #e9e0d0; + color: #7c6442; + border-radius: 4px; + font-family: var(--serif); + font-size: 17px; + flex-shrink: 0; +} +.guild-item .guild-avatar { + width: 29px; + height: 29px; + font-size: 13px; + color: #d8c8ad; + background: #614d43; +} +.guild-item:nth-child(3n + 2) .guild-avatar { + background: #4a5650; + color: #d0d9c7; +} +.guild-item:nth-child(3n) .guild-avatar { + background: #514753; + color: #d6c9d9; +} +.guild-item img { + width: 29px; + height: 29px; + object-fit: cover; + border-radius: 4px; +} +.status-pill__dot { + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background: #ba9365; +} +.status-pill--ok .status-pill__dot { + background: #a7bb91; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 55px; +} +.profile { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 0; + border-top: 1px solid #a48c7430; +} +.profile-avatar { + display: grid; + place-items: center; + height: 31px; + width: 31px; + border-radius: 50%; + background: #675147; + color: #e9d8bf; + font-family: var(--serif); + font-size: 17px; + flex-shrink: 0; +} +.profile__text { + min-width: 0; + flex: 1; +} +.profile__text strong { + display: block; + font-size: 11px; + color: #e4d8ca; + overflow: hidden; + text-overflow: ellipsis; +} +.profile__text > span { + display: block; + font-size: 9px; + color: #b5a297; + margin-top: 4px; +} +.icon-button { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 6px; +} +.icon-button:hover { + border-color: currentColor; +} +.icon-button svg { + width: 17px; + height: 17px; +} +.workspace { + min-width: 0; +} +.topbar { + height: 71px; + border-bottom: 1px solid var(--line); + padding: 0 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + background: #fbfaf6; +} +.breadcrumb { + display: flex; + gap: 17px; + color: var(--muted); + font-size: 11px; +} +.breadcrumb > span { + color: #b1a797; +} +.breadcrumb strong { + color: #51463f; + font-weight: 500; +} +.content { + max-width: 1450px; + margin: auto; + padding: 34px 40px 0; + outline: none; +} +.page-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + margin-bottom: 29px; +} +.page-heading h1 { + font-size: clamp(29px, 2.7vw, 40px); + line-height: 1.25; + letter-spacing: -0.9px; + margin-top: 8px; +} +.page-heading .muted { + margin-top: 8px; + font-size: 12px; +} +.guild-header { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + background: #fffdf8; + border-radius: 5px; + padding: 11px 16px; + max-width: 280px; + min-width: 205px; +} +.guild-header img { + width: 33px; + height: 33px; + border-radius: 4px; +} +.guild-header > div { + min-width: 0; +} +.guild-header strong { + font-size: 12px; + display: block; + margin-top: 4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.overview-stats { + margin: 23px 0 29px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 5px; +} +.overview-stat { + padding: 20px 22px; + min-width: 0; +} +.overview-stat + .overview-stat { + border-left: 1px solid var(--line); +} +.stat-heading { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + color: #777064; + font-size: 11px; +} +.stat-heading svg { + color: #a98e60; + width: 17px; + height: 17px; +} +.stat-value { + font-size: 35px; + line-height: 1.3; + font-family: var(--serif); + font-weight: 400; + display: block; + margin: 11px 0 6px; + font-variant-numeric: tabular-nums; +} +.stat-value--status { + font-size: 28px; + line-height: 1.62; +} +.stat-value--status.is-connected { + color: #4c6854; +} +.text-link { + display: inline-flex; + align-items: center; + gap: 12px; + background: none; + border: none; + padding: 0; + font-size: 11px; + color: #765e3b; + text-align: left; +} +.text-link:hover { + color: var(--wine); + text-decoration: underline; + text-underline-offset: 4px; +} +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 5px; + padding: 25px; + min-width: 0; +} +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 25px; +} +.panel__header h2 { + font-size: 23px; +} +.panel__header .muted { + font-size: 11px; + margin-top: 5px; +} +.section-title { + display: flex; + align-items: center; + gap: 12px; +} +.section-icon { + display: grid; + place-items: center; + width: 35px; + height: 35px; + color: #a58a5e; + background: #f2eddf; + border: 1px solid #e7deca; + border-radius: 4px; + flex-shrink: 0; +} +.section-icon svg { + width: 18px; + height: 18px; +} +.subtle-tag { + font-size: 8px; + letter-spacing: 0.8px; + color: #8b7a5c; + border: 1px solid #e3dac6; + padding: 5px 7px; + border-radius: 3px; + white-space: nowrap; +} +.field { + display: grid; + gap: 9px; + margin-top: 24px; +} +.field > label, +.custom-command label { + font-size: 12px; + font-weight: 600; + color: #51483f; +} +.field > p { + margin-top: -4px; +} +input, +select, +textarea { + width: 100%; + border: 1px solid #e2ddd1; + border-radius: 4px; + background: #fffefa; + color: var(--ink); + min-height: 41px; + padding: 10px 12px; +} +input::placeholder, +textarea::placeholder { + color: #979184; + font-size: 11px; +} +input:disabled, +select:disabled, +textarea:disabled { + background: #f2f0e9; + cursor: not-allowed; +} +input[type="checkbox"] { + width: 15px; + height: 15px; + min-height: 0; + margin: 0; + accent-color: #70503e; + flex-shrink: 0; + cursor: pointer; +} +textarea { + resize: vertical; + line-height: 1.7; +} +.input-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.input-row input { + min-width: 0; + flex: 1; +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 39px; + padding: 9px 15px; + border: 1px solid var(--line); + background: var(--panel); + color: #635541; + border-radius: 4px; + white-space: nowrap; + font-size: 11px; + font-weight: 500; + transition: + background 0.15s, + border-color 0.15s, + transform 0.15s; +} +.button svg { + width: 15px; + height: 15px; +} +.button > span { + margin-left: 5px; +} +.button:hover:not(:disabled) { + background: #f1ecdf; + border-color: #c9b99a; +} +.button:active:not(:disabled) { + transform: translateY(1px); +} +.button--primary, +.button--dark { + background: var(--wine); + color: #fff7e8; + border-color: var(--wine); +} +.button--primary:hover:not(:disabled), +.button--dark:hover:not(:disabled) { + background: #634249; + border-color: #634249; +} +.button--soft { + background: #f0eadc; + border-color: #e6dcc7; +} +.button--ghost { + background: transparent; + border-color: transparent; +} +.button--danger { + color: #9d443b; + border-color: #e4d1c9; +} +.button-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.multi-picker { + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; +} +.multi-picker__search { + border: none; + border-bottom: 1px solid var(--line); + border-radius: 0; + min-height: 36px; + background: #faf8f2; +} +.multi-picker__list { + max-height: 145px; + overflow-y: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + padding: 10px; + scrollbar-width: thin; +} +.multi-picker__list label { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + min-width: 0; + overflow-wrap: anywhere; +} +.multi-picker__list label:hover { + background: #f6f1e5; +} +.multi-picker__list label:has(input:checked) { + background: #f1ecdf; +} +.role-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--role-color, #a38d60); + flex-shrink: 0; +} +.multi-picker > .chip-list { + border-top: 1px solid var(--line); + padding: 10px; + background: #fcfaf5; +} +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} +.chip-list .muted { + font-size: 10px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid #ded4be; + border-radius: 3px; + padding: 4px 7px; + background: #f2ebdb; + font-size: 10px; + color: #715d37; + overflow-wrap: anywhere; +} +.chip button { + display: grid; + place-items: center; + background: none; + border: 0; + padding: 0 2px; + font-size: 15px; + line-height: 1; + color: #8b7752; +} +.panel-footnote { + margin: 25px -25px -25px; + padding: 13px 25px; + background: #faf8f2; + border-top: 1px solid var(--line); + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: #898071; + border-radius: 0 0 5px 5px; +} +.panel-footnote svg { + width: 14px; + height: 14px; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid var(--line); + border-radius: 4px; + background: #faf8f2; + margin-bottom: 22px; +} +.stat-card { + padding: 18px; + display: flex; + flex-direction: column; + gap: 7px; + color: #827765; + font-size: 11px; +} +.stat-card + .stat-card { + border-left: 1px solid var(--line); +} +.stat-card strong { + font-family: var(--serif); + font-size: 28px; + font-weight: 400; + color: var(--ink); +} +.stat-card .stat-value--small { + font-size: 18px; + line-height: 2; +} +.toolbar { + display: flex; + gap: 12px; + margin-bottom: 12px; +} +.toolbar .search-field { + flex: 1; +} +.toolbar select { + width: 205px; +} +.command-list { + display: grid; + gap: 12px; + margin-top: 22px; +} +.command-card { + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; +} +.command-card--changed { + border-color: #baa06c; +} +.command-card__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 16px 18px; + background: #fcfaf5; +} +.command-card__title { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.command-card__title strong { + font-family: Consolas, monospace; + font-size: 13px; + color: #795a46; + font-weight: 500; +} +.command-card__title span { + color: #999080; + font-size: 10px; +} +.command-card__body { + padding: 18px; + border-top: 1px solid var(--line); + display: grid; + grid-template-columns: 1fr 1fr; + align-items: start; + gap: 20px; +} +.command-card__body .field { + margin: 0; +} +.command-card__body .field > label, +.command-card__body .field > span { + font-size: 11px; +} +.command-card__body .multi-picker__list { + grid-template-columns: 1fr; +} +.command-card__body .chip-list { + margin-top: 8px; +} +.command-card__body .multi-picker > .chip-list { + margin-top: 0; +} +.command-card__note { + grid-column: 1 / -1; + font-size: 11px; + color: #8d6b39; +} +.toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: #88775e; +} +.toggle input { + appearance: none; + width: 29px; + height: 17px; + background: #d9d4c8; + border: 0; + border-radius: 12px; + padding: 2px; + cursor: pointer; + position: relative; +} +.toggle input::before { + content: ""; + display: block; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fffdf6; + box-shadow: 0 1px 3px #0002; + transition: transform 0.15s; +} +.toggle input:checked { + background: #886644; +} +.toggle input:checked::before { + transform: translateX(12px); +} +.custom-command { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: 24px; + padding-top: 22px; + border-top: 1px solid var(--line); +} +.custom-command p { + margin-top: 5px; +} +.warnings-layout { + display: grid; + grid-template-columns: minmax(270px, 0.8fr) minmax(0, 1.2fr); + gap: 24px; + align-items: start; +} +.warnings-pane .panel__header { + align-items: start; +} +.warnings-pane > .muted { + font-size: 11px; + margin-top: 8px; +} +.stats-grid--compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.selected-user-card { + padding: 18px; + margin: 24px 0 18px; + border: 1px solid #e3d9c7; + background: #f5efe3; + display: grid; + gap: 8px; + border-radius: 4px; +} +.selected-user-card strong { + font-size: 12px; + overflow-wrap: anywhere; +} +.member-list { + display: grid; + gap: 5px; + margin-top: 12px; +} +.member-item { + background: #faf7ee; + border: 1px solid var(--line); + text-align: left; + padding: 12px; + border-radius: 4px; +} +.member-item:hover, +.member-item.active { + border-color: var(--gold); + background: #f0e7d4; +} +.member-item__meta { + display: grid; + gap: 4px; +} +.member-item__name { + font-size: 12px; +} +.member-item__id { + font-size: 10px; + color: #897c69; +} +#warningsList { + list-style: none; + padding: 0; + margin: 0; +} +.warning-item { + border-bottom: 1px solid var(--line); + padding: 16px 0; +} +.warning-item > p { + overflow-wrap: anywhere; +} +.warning-item__meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 10px; + color: #998973; + margin: 7px 0; +} +.warning-item .button { + min-height: 28px; + padding: 4px 0; + font-size: 10px; +} +.divider { + height: 1px; + background: var(--line); + margin: 24px 0; +} +.field__meta { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 11px; + color: #967043; +} +.field__meta .field-hint { + white-space: nowrap; +} +.empty-state { + padding: 30px 18px; + text-align: center; + font-size: 12px; + line-height: 1.8; + color: #978975; + background: #f7f3e9; + border: 1px dashed #ded3bc; + border-radius: 4px; +} +.empty-state h2 { + margin-bottom: 10px; +} +.sidebar .empty-state { + padding: 16px 10px; + background: transparent; + border-color: #776454; + font-size: 11px; + color: #c4b19a; +} +.error-banner, +.callout { + display: flex; + align-items: center; + gap: 15px; + padding: 18px 22px; + margin-bottom: 24px; + border: 1px solid #dbc29c; + background: #f5ecd9; + border-radius: 4px; + font-size: 12px; +} +.error-banner { + background: #f5e6e0; + border-color: #d8b7ac; + color: #8d4c3b; +} +.error-banner > div, +.callout > div { + flex: 1; +} +.callout > svg { + color: #987e4f; +} +.callout p, +.error-banner p { + font-size: 11px; + margin-top: 3px; +} +.save-bar { + position: sticky; + bottom: 18px; + background: #fffdf6f5; + backdrop-filter: blur(12px); + padding: 15px 20px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + border: 1px solid #b9a47b; + box-shadow: 0 6px 35px #4f362020; + border-radius: 5px; + margin-top: 20px; + z-index: 10; +} +.save-bar > div:first-child { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.save-bar strong { + font-size: 12px; + font-weight: 500; +} +.save-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #aa8652; +} +.toast-container { + position: fixed; + bottom: 25px; + right: 25px; + z-index: 100; + display: grid; + gap: 10px; + max-width: calc(100vw - 50px); +} +.toast { + padding: 16px 22px; + background: var(--wine); + color: #fff7e8; + border: 1px solid #a88c60; + box-shadow: 0 5px 24px #30222425; + font-size: 12px; + border-radius: 4px; + opacity: 0; + transform: translateY(8px); + transition: + opacity 0.2s, + transform 0.2s; +} +.toast--visible { + opacity: 1; + transform: none; +} +.toast--error { + background: #873f36; +} +.modal { + border: 1px solid #cbbb9b; + border-radius: 6px; + width: min(460px, calc(100vw - 36px)); + background: var(--panel); + color: var(--ink); + padding: 0; + box-shadow: 0 24px 100px #24181940; +} +.modal::backdrop { + background: #30222480; + backdrop-filter: blur(3px); +} +.modal__content { + padding: 32px; +} +.modal h2 { + margin: 14px 0 12px; + font-size: 30px; +} +.modal label { + display: block; + margin: 22px 0 8px; + font-size: 12px; +} +.modal .field-hint { + margin-top: 12px; +} +.modal .button-row { + justify-content: flex-end; + margin-top: 24px; +} +.skeleton-line { + height: 22px; + background: linear-gradient( + 90deg, + #aa927415 25%, + #aa92742b 50%, + #aa927415 75% + ); + background-size: 200% 100%; + border-radius: 3px; + animation: shimmer 1.6s infinite; + margin: 12px 0; +} +.skeleton-line:nth-child(2) { + width: 75%; +} +.skeleton-line:nth-child(3) { + width: 55%; +} +.skeleton-panel { + margin-top: 20px; + min-height: 250px; +} +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--line); + border-top-color: var(--gold); + border-radius: 50%; + animation: spin 0.7s linear infinite; + margin-top: 20px; +} +@keyframes shimmer { + to { + background-position: -200% 0; + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.landing-body { + background: #f4f0e6; +} +.landing-shell { + max-width: 1370px; + padding: 0 65px; + margin: auto; +} +.landing-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32px 0; + border-bottom: 1px solid #dcd2bf; +} +.landing-nav .brand > span { + color: var(--wine); +} +.landing-main { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 30px; + padding: 65px 0 60px; + align-items: center; +} +.landing-copy h1 { + font-size: clamp(50px, 5.8vw, 80px); + line-height: 1.05; + letter-spacing: -2.5px; + margin: 24px 0; +} +.landing-copy h1 em { + color: #94774c; +} +.landing-description { + max-width: 405px; + font-size: 13px; + color: #8a7c68; + line-height: 1.9; + margin-bottom: 28px; +} +.landing-copy .button { + min-height: 47px; + padding: 12px 20px; +} +.landing-session { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + color: #928571; + font-size: 10px; + margin-top: 18px; +} +.landing-session .text-link { + font-size: 10px; +} +.landing-art { + position: relative; + background: #eae1cf; + border: 1px solid #d4c3a2; + border-radius: 180px 180px 3px 3px; + min-height: 480px; + display: flex; + align-items: center; +} +.landing-art::before { + content: ""; + position: absolute; + inset: 12px; + border: 1px solid #cdbb9666; + border-radius: inherit; +} +.landing-art img { + width: 100%; + transform: scale(1.1); +} +.banner { + display: none; + margin-top: 18px; + font-size: 11px; + color: #a18b68; +} +@media (min-width: 1600px) { + .content { + padding-top: 42px; + } + .overview-stat { + padding: 24px; + } +} +@media (max-width: 1200px) { + .app-shell { + grid-template-columns: 220px minmax(0, 1fr); + } + .sidebar { + padding-right: 14px; + padding-left: 14px; + } + .tab { + gap: 9px; + font-size: 11px; + } + .tab.active::before { + left: -15px; + } + .topbar { + padding: 0 26px; + } + .content { + padding: 28px 26px 0; + } + .panel { + padding: 21px; + } + .admin-panel .subtle-tag { + display: none; + } + .panel-footnote { + margin: 24px -21px -21px; + padding: 13px 21px; + } + .overview-stat { + padding: 18px 15px; + } + .stat-heading { + font-size: 10px; + } + .stat-heading svg { + width: 14px; + height: 14px; + } + .guild-header { + min-width: 175px; + padding: 10px; + } + .panel__header--split { + flex-wrap: wrap; + } + .section-title { + gap: 10px; + } + .section-title h2 { + font-size: 21px; + } + .section-icon { + display: none; + } + .landing-shell { + padding: 0 40px; + } + .landing-art { + min-height: 420px; + } +} +@media (max-width: 1000px) { + .overview-stats { + grid-template-columns: repeat(2, 1fr); + } + .overview-stat:nth-child(3) { + border-left: 0; + } + .overview-stat:nth-child(n + 3) { + border-top: 1px solid var(--line); + } + .stat-heading { + font-size: 11px; + } + .page-heading { + align-items: start; + } + .page-heading h1 { + font-size: 30px; + } + .guild-header { + max-width: 180px; + min-width: 155px; + margin-top: 15px; + } + .guild-header .guild-avatar, + .guild-header img { + display: none; + } + .warnings-layout { + grid-template-columns: 1fr; + } + .custom-command { + align-items: start; + flex-direction: column; + gap: 12px; + } + .custom-command .input-row { + width: 100%; + } + .command-card__body { + grid-template-columns: 1fr; + } + .landing-main { + gap: 25px; + } + .landing-art { + min-height: 370px; + } + .landing-copy h1 { + font-size: 60px; + } +} +@media (max-width: 760px) { + .app-shell { + display: block; + } + .sidebar { + position: relative; + height: auto; + overflow: visible; + padding: 20px 20px 16px; + } + .brand > span { + font-size: 26px; + } + .brand img { + width: 35px; + height: 39px; + } + .sidebar__section-label { + display: none; + } + .tabs { + display: flex; + margin-top: 22px; + gap: 4px; + } + .tab { + justify-content: center; + flex: 1; + font-size: 11px; + padding: 11px 8px; + width: auto; + } + .tab.active::before, + .nav-arrow, + .nav-count { + display: none; + } + .sidebar-search { + margin: 17px 0 8px; + } + .sidebar-search input { + min-height: 34px; + } + .guild-list { + display: flex; + overflow-x: auto; + gap: 8px; + padding-bottom: 3px; + } + .guild-item { + width: auto; + flex-shrink: 0; + max-width: 210px; + } + .sidebar-bottom { + position: absolute; + right: 20px; + top: 16px; + margin: 0; + padding: 0; + } + .profile { + padding: 0; + border: 0; + } + .profile-avatar, + .profile__text { + display: none; + } + .profile .icon-button { + color: #ccb898; + } + .topbar { + height: 53px; + padding: 0 22px; + } + .breadcrumb { + font-size: 10px; + gap: 10px; + } + .content { + padding: 25px 22px 0; + } + .page-heading { + margin-bottom: 22px; + } + .page-heading h1 { + font-size: 32px; + } + .guild-header { + display: none; + } + .overview-stats { + margin: 18px 0 23px; + } + .overview-stat { + padding: 18px; + } + .section-icon { + display: grid; + } + .section-title h2 { + font-size: 23px; + } + .save-bar { + bottom: 10px; + padding: 12px 14px; + } + .save-bar strong { + font-size: 11px; + } + .save-bar .button { + padding: 8px 11px; + } + .callout, + .error-banner { + flex-wrap: wrap; + } + .callout > div { + min-width: 200px; + } + .landing-shell { + padding: 0 25px; + } + .landing-nav { + padding: 23px 0; + } + .landing-main { + padding: 40px 0; + } + .landing-copy h1 { + font-size: 48px; + letter-spacing: -1.5px; + } + .landing-art { + min-height: 340px; + } + .landing-description { + font-size: 12px; + } +} +@media (max-width: 520px) { + .sidebar { + padding: 18px 16px 12px; + } + .tab { + font-size: 10px; + gap: 6px; + flex: 1 1 0; + flex-direction: column; + min-width: 0; + min-height: 62px; + padding: 8px 4px; + white-space: normal; + text-align: center; + } + .tab svg { + width: 14px; + height: 14px; + } + .topbar { + padding: 0 18px; + } + .content { + padding: 23px 16px 0; + } + .page-heading h1 { + font-size: 31px; + } + .overview-stat { + padding: 16px; + } + .stat-heading { + font-size: 10px; + } + .stat-value { + font-size: 31px; + } + .stat-value--status { + font-size: 25px; + } + .panel { + padding: 20px; + } + .panel__header { + margin-bottom: 20px; + } + .section-title h2 { + font-size: 23px; + } + .panel__header .muted { + font-size: 10px; + } + .section-icon { + display: none; + } + .field { + margin-top: 22px; + } + .input-row { + flex-wrap: wrap; + } + .input-row input { + flex-basis: 170px; + } + .input-row .button { + flex: 1; + } + .panel-footnote { + margin: 23px -20px -20px; + padding: 12px 20px; + font-size: 9px; + } + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + .stat-card:nth-child(3) { + border-left: 0; + } + .stat-card:nth-child(n + 3) { + border-top: 1px solid var(--line); + } + .toolbar { + flex-direction: column; + } + .toolbar select { + width: 100%; + } + .command-card__header { + padding: 14px; + } + .command-card__title { + gap: 6px; + } + .command-card__title span { + font-size: 9px; + } + .command-card__body { + padding: 14px; + } + .save-bar { + flex-wrap: wrap; + justify-content: center; + } + .save-bar > div:first-child { + justify-content: center; + } + .save-bar .button-row { + width: 100%; + justify-content: center; + } + .save-bar .button { + flex: 1; + } + .landing-main { + grid-template-columns: 1fr; + padding: 42px 0 30px; + gap: 35px; + } + .landing-copy h1 { + font-size: clamp(47px, 15.2vw, 62px); + } + .landing-description { + max-width: 330px; + } + .landing-art { + min-height: 310px; + height: 340px; + border-radius: 150px 150px 3px 3px; + } + .landing-art img { + max-height: 290px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } } -@keyframes rise { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } +.sidebar .brand { + margin-bottom: 40px; } - -@media (max-width: 1080px) { - .layout { - grid-template-columns: 1fr; - } - - .warnings-layout { - grid-template-columns: 1fr; - } - - .save-bar { - position: static; - } - - .topbar { - flex-direction: column; - align-items: flex-start; - } - - .toolbar { - grid-template-columns: 1fr; - } +.content { + padding-bottom: 40px; +} +.page-heading h1 { + margin: 0; +} +.guild-header { + min-width: 0; +} +.guild-header strong { + margin: 0; +} +.overview-stats { + margin-top: 0; +} +.stat-heading { + min-height: 24px; + font-size: 12px; +} +.stat-heading .icon-button { + width: 24px; + height: 24px; + font-size: 19px; + padding: 0; +} +.stat-heading .text-link { + font: inherit; + color: inherit; +} +.stat-value--status { + line-height: 1.3; + font-size: 35px; +} +.admin-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 32px; + align-items: start; +} +.admin-fields > .field { + margin-top: 0; + align-content: start; +} +#adminRolesLabel { + font-size: 12px; + font-weight: 600; + color: #51483f; +} +.warning-actions { + margin-top: 20px; +} +.selected-user-card + .warning-actions { + margin-top: 0; +} +.selected-user-card[hidden] + .warning-actions { + margin-top: 20px; +} +#warningsSearchStatus:empty, +#warningsSelectedHint:empty, +.landing-session:has(#sessionStatus:empty) { + display: none; +} +.callout > p, +.error-banner > p { + flex: 1; + margin: 0; +} +.landing-main { + min-height: calc(100vh - 116px); +} +.landing-copy h1 { + margin-top: 0; +} +@media (max-width: 1000px) { + .admin-fields { + grid-template-columns: 1fr; + gap: 24px; + } +} +@media (max-width: 760px) { + .sidebar .brand { + margin-bottom: 0; + } + .content { + padding-bottom: 28px; + } +} +@media (max-width: 520px) { + .stat-value--status { + font-size: 28px; + } + .stat-heading { + font-size: 10px; + } + .landing-main { + min-height: 0; + } }