Improve dashboard UX and bot invite flow

This commit is contained in:
2026-01-28 10:41:22 +01:00
parent ac120bc4cb
commit 10caf20369
5 changed files with 1418 additions and 336 deletions
+96 -4
View File
@@ -38,6 +38,10 @@ const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN ||
config.discordBotToken ||
config.token;
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
10,
);
const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl ||
@@ -59,9 +63,14 @@ const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
const COMMANDS_PATH = path.join(__dirname, 'commands.json');
const GUILD_CACHE_TTL_MS = 5 * 60 * 1000;
const BOT_GUILD_CACHE_TTL_MS = 60 * 1000;
const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.';
const sessionStore = new Map();
const botGuildCache = {
guildIds: null,
fetchedAt: 0,
};
const parseCookies = (headerValue) => {
const cookies = {};
@@ -153,12 +162,12 @@ const getDiscordStatusCode = (error) => {
const sendDiscordApiError = (res, error, fallbackMessage) => {
const status = getDiscordStatusCode(error);
if (status === 401 || status === 403) {
return res.status(401).json({error: 'Discord authentication expired'});
return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'});
}
if (status === 429) {
return res.status(503).json({error: 'Discord rate limit exceeded'});
return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'});
}
return res.status(502).json({error: fallbackMessage});
return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage});
};
const buildAuthorizationUrl = (state) => {
@@ -226,6 +235,55 @@ const fetchDiscordGuildRoles = async (guildId) => {
return response.data;
};
const fetchDiscordBotGuilds = async () => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const getCachedBotGuildIds = () => {
if (!Array.isArray(botGuildCache.guildIds)) {
return null;
}
if (!botGuildCache.fetchedAt) {
return null;
}
if (Date.now() - botGuildCache.fetchedAt > BOT_GUILD_CACHE_TTL_MS) {
return null;
}
return botGuildCache.guildIds;
};
const setCachedBotGuildIds = (guildIds) => {
botGuildCache.guildIds = guildIds;
botGuildCache.fetchedAt = Date.now();
};
const buildInviteUrl = (guildId) => {
if (!DISCORD_CLIENT_ID) {
return null;
}
const permissions = Number.isNaN(DASHBOARD_INVITE_PERMISSIONS) ? 0 : DASHBOARD_INVITE_PERMISSIONS;
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
scope: 'bot applications.commands',
permissions: String(permissions),
disable_guild_select: 'true',
});
if (guildId) {
params.set('guild_id', guildId);
}
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const cacheSessionGuilds = (session, guilds) => {
session.guilds = guilds;
session.guildsFetchedAt = Date.now();
@@ -434,6 +492,32 @@ const createDashboardApp = () => {
}
});
app.get('/api/bot/guilds', ensureSession, async (req, res) => {
try {
const cached = getCachedBotGuildIds();
if (cached) {
return res.json({guildIds: cached});
}
const guilds = await fetchDiscordBotGuilds();
const guildIds = Array.isArray(guilds) ? guilds.map((guild) => guild.id) : [];
setCachedBotGuildIds(guildIds);
return res.json({guildIds});
} catch (error) {
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch bot guilds');
}
});
app.get('/api/invite-url', ensureSession, (req, res) => {
if (!DISCORD_CLIENT_ID) {
return res.status(500).json({error: 'MISSING_CLIENT_ID', message: 'Discord client ID not configured.'});
}
const inviteUrl = buildInviteUrl(req.query.guildId);
return res.json({inviteUrl});
});
app.get(
'/api/guilds/:guildId/permissions',
ensureSession,
@@ -482,8 +566,16 @@ const createDashboardApp = () => {
const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles});
} catch (error) {
const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
}
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'Discord bot token not configured'});
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch roles');
}
+84 -20
View File
@@ -1,3 +1,19 @@
const createFetchError = async (response) => {
let payload = {};
try {
payload = await response.json();
} catch (error) {
payload = {};
}
const message = payload.message || payload.error || `Request failed with status ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.code = payload.error;
error.details = payload.details;
error.payload = payload;
return error;
};
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
credentials: 'include',
@@ -9,39 +25,76 @@ const fetchJson = async (url, options = {}) => {
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const message = body.error || `Request failed with status ${response.status}`;
const error = new Error(message);
error.details = body.details;
throw error;
throw await createFetchError(response);
}
return response.json().catch(() => ({}));
};
const setBanner = (bannerEl, message, type = 'error') => {
if (!bannerEl) {
const showToast = (message, type = 'success') => {
const container = document.getElementById('toastContainer');
if (!container) {
return;
}
bannerEl.textContent = message;
bannerEl.classList.remove('banner--error', 'banner--success');
bannerEl.classList.add(type === 'success' ? 'banner--success' : 'banner--error');
bannerEl.style.display = 'block';
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast--visible');
}, 10);
setTimeout(() => {
toast.classList.remove('toast--visible');
setTimeout(() => toast.remove(), 200);
}, 3200);
};
const clearBanner = (bannerEl) => {
const setErrorBanner = (bannerEl, message, details) => {
if (!bannerEl) {
return;
}
bannerEl.textContent = '';
bannerEl.style.display = 'none';
const messageEl = bannerEl.querySelector('[data-role="error-message"]');
const copyButton = bannerEl.querySelector('[data-role="error-copy"]');
if (messageEl) {
messageEl.textContent = message;
}
if (copyButton) {
copyButton.onclick = async () => {
const payload = details || {message};
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
showToast('Error details copied.', 'success');
} catch (error) {
showToast('Unable to copy details.', 'error');
}
};
}
bannerEl.hidden = false;
};
const clearErrorBanner = (bannerEl) => {
if (!bannerEl) {
return;
}
bannerEl.hidden = true;
};
const formatUserDisplay = (user) => {
if (!user) {
return 'Not signed in';
}
return `${user.username}#${user.discriminator ?? '0000'}`;
const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : '';
return `${user.username}${tag}`;
};
const debounce = (callback, wait = 300) => {
let timeoutId;
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => callback(...args), wait);
};
};
const initIndexPage = async () => {
@@ -61,13 +114,19 @@ const initIndexPage = async () => {
loginButton.hidden = true;
dashboardButton.hidden = false;
logoutButton.hidden = false;
clearBanner(banner);
if (banner) {
banner.textContent = '';
banner.style.display = 'none';
}
} catch (error) {
sessionStatus.textContent = 'You are not signed in yet.';
loginButton.hidden = false;
dashboardButton.hidden = true;
logoutButton.hidden = true;
setBanner(banner, 'Log in with Discord to access the dashboard.', 'error');
if (banner) {
banner.textContent = 'Log in with Discord to access the dashboard.';
banner.style.display = 'block';
}
}
logoutButton?.addEventListener('click', async () => {
@@ -78,7 +137,10 @@ const initIndexPage = async () => {
dashboardButton.hidden = true;
logoutButton.hidden = true;
} catch (error) {
setBanner(banner, error.message || 'Failed to log out.', 'error');
if (banner) {
banner.textContent = error.message || 'Failed to log out.';
banner.style.display = 'block';
}
}
});
};
@@ -89,7 +151,9 @@ document.addEventListener('DOMContentLoaded', () => {
window.dashboardApi = {
fetchJson,
setBanner,
clearBanner,
showToast,
setErrorBanner,
clearErrorBanner,
formatUserDisplay,
debounce,
};
+162 -95
View File
@@ -7,134 +7,201 @@
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="topbar__user">
<span class="badge">Guild dashboard</span>
<strong id="userDisplay">Loading session...</strong>
<span class="muted" id="sessionExpiry"></span>
<div class="app-shell">
<header class="navbar">
<div class="navbar__brand">
<div class="brand-mark">DB</div>
<div>
<p class="muted">Discord Bot Dashboard</p>
<strong id="userDisplay">Loading session...</strong>
<p class="muted" id="sessionExpiry"></p>
</div>
</div>
<div class="button-row">
<a class="button" href="/">Home</a>
<button class="button button--ghost" id="logoutButton">Log out</button>
<div class="navbar__actions">
<a class="button button--ghost" href="/">Home</a>
<button class="button button--primary" id="logoutButton">Log out</button>
</div>
</header>
<div class="banner" id="banner"></div>
<div class="error-banner" id="errorBanner" hidden>
<div>
<strong>Something went wrong.</strong>
<p data-role="error-message"></p>
</div>
<button class="button button--ghost" data-role="error-copy">Copy error details</button>
</div>
<div class="toast-container" id="toastContainer"></div>
<div class="layout">
<aside class="panel">
<div class="section">
<div class="section__header">
<h2>Manageable guilds</h2>
</div>
<div class="guild-list" id="guildList"></div>
<div class="empty-state" id="guildEmpty" hidden>
No manageable guilds found. Make sure you have Manage Server permissions.
<aside class="sidebar">
<div class="sidebar__header">
<div>
<h2>Guilds</h2>
<p class="muted">Pick a server to manage.</p>
</div>
<input type="search" id="guildSearch" placeholder="Search guilds" />
</div>
<div class="guild-list" id="guildList"></div>
<div class="skeleton-list" id="guildListSkeleton" hidden>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
<div class="empty-state" id="guildEmpty" hidden>
No manageable guilds found. Make sure you have Manage Server permissions.
</div>
</aside>
<main>
<main class="content">
<section class="guild-header">
<div class="guild-header__info">
<img id="guildHeaderIcon" alt="" />
<div>
<h1 id="guildHeaderName">Select a guild</h1>
<div class="guild-header__status">
<span class="status-badge" id="guildStatusBadge"></span>
<span class="muted" id="guildStatusNote"></span>
</div>
</div>
</div>
<div class="guild-header__actions">
<button class="button button--primary" id="inviteButton" hidden>Invite bot</button>
<button class="button button--ghost" id="refreshBotButton" hidden>Refresh status</button>
</div>
</section>
<div class="tabs">
<button class="tab active" data-tab="permissions">Permissions</button>
<button class="tab active" data-tab="settings">Settings</button>
<button class="tab" data-tab="permissions">Permissions</button>
<button class="tab" data-tab="warnings">Warnings</button>
</div>
<section class="panel" id="permissionsPanel">
<div class="section">
<div class="section__header">
<div>
<h2>Admin access</h2>
<p class="muted">Choose who can edit the bot configuration.</p>
</div>
<section class="panel" id="settingsPanel">
<div class="panel__header">
<div>
<h2>Admin access</h2>
<p class="muted">Control who can edit bot configuration and permissions.</p>
</div>
</div>
<div class="callout" id="botInactiveCallout" hidden>
<strong>Bot not added yet.</strong>
<p class="muted">The bot isn’t in this server yet. Invite it to configure settings.</p>
<p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p>
</div>
<div class="field">
<span>
Admin roles
<span class="tooltip" title="Members with these roles can edit dashboard settings.">?</span>
</span>
<div id="adminRolesPicker"></div>
</div>
<div class="field">
<span>
Admin user IDs
<span class="tooltip" title="Paste one or more user IDs separated by commas or spaces.">?</span>
</span>
<div class="input-row">
<input type="text" id="adminUserInput" placeholder="Paste user IDs" />
<button class="button" id="addAdminUserButton">Add</button>
</div>
<label class="field">
<span>Admin roles</span>
<select class="multi-select" id="adminRolesSelect" multiple></select>
</label>
<label class="field">
<span>Add admin user ID</span>
<div class="button-row">
<input type="text" id="adminUserInput" placeholder="123456789" />
<button class="button" id="addAdminUserButton">Add user</button>
</div>
</label>
<div class="chip-list" id="adminUserChips"></div>
</div>
</section>
<div class="section">
<div class="section__header">
<div>
<h2>Command permissions</h2>
<p class="muted">Configure who can run each command. Empty restrictions allow everyone.</p>
</div>
<div class="button-row">
<input type="text" id="newCommandInput" placeholder="new-command" />
<button class="button" id="addCommandButton">Add command</button>
</div>
<section class="panel" id="permissionsPanel" hidden>
<div class="panel__header panel__header--split">
<div>
<h2>Command permissions</h2>
<p class="muted">Restrict commands to roles or specific users.</p>
</div>
<div class="button-row">
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
<button class="button" id="copyFromGuild">Copy from another guild</button>
</div>
<table class="table" id="commandsTable">
<thead>
<tr>
<th>Command</th>
<th>Allowed roles</th>
<th>Allowed users</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="button-row">
<button class="button button--primary" id="savePermissionsButton">Save permissions</button>
<span class="muted" id="permissionsStatus"></span>
<div class="field">
<span>
Add command
<span class="tooltip" title="Create new command overrides for custom commands.">?</span>
</span>
<div class="input-row">
<input type="text" id="newCommandInput" placeholder="command-name" />
<button class="button" id="addCommandButton">Add</button>
</div>
</div>
<div class="command-list" id="commandsList"></div>
<div class="empty-state" id="commandsEmpty" hidden>
No commands configured yet. Add one or invite the bot to sync commands.
</div>
</section>
<section class="panel" id="warningsPanel" hidden>
<div class="section">
<div class="section__header">
<div>
<h2>Warnings lookup</h2>
<p class="muted">Fetch recent warnings for a user.</p>
</div>
</div>
<label class="field">
<span>User ID</span>
<div class="button-row">
<div class="warnings-layout">
<div class="warnings-pane">
<h2>User lookup</h2>
<p class="muted">Fetch warnings for a user ID.</p>
<div class="field">
<label for="warningsUserInput">User ID</label>
<input type="text" id="warningsUserInput" placeholder="123456789" />
<button class="button" id="fetchWarningsButton">Fetch warnings</button>
</div>
</label>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div>
<ul id="warningsList"></ul>
</div>
<div class="section">
<div class="section__header">
<div>
<h2>Add warning</h2>
<p class="muted">Record a new warning and sync strikes.</p>
</div>
<button class="button" id="fetchWarningsButton">Fetch warnings</button>
<div class="spinner" id="warningsSpinner" hidden></div>
</div>
<div class="warnings-pane">
<h2>Warnings</h2>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div>
<ul id="warningsList"></ul>
<div class="divider"></div>
<h3>Add warning</h3>
<div class="field">
<label for="newWarningUserInput">User ID</label>
<input type="text" id="newWarningUserInput" placeholder="123456789" />
</div>
<div class="field">
<label for="warningReasonInput">Reason</label>
<textarea id="warningReasonInput" placeholder="Reason for warning" maxlength="200"></textarea>
<div class="field__meta">
<span class="muted" id="warningCharCount">0/200</span>
<span class="muted" id="warningStatus"></span>
</div>
</div>
<button class="button button--primary" id="addWarningButton">Add warning</button>
</div>
<label class="field">
<span>User ID</span>
<input type="text" id="newWarningUserInput" placeholder="123456789" />
</label>
<label class="field">
<span>Reason</span>
<textarea id="warningReasonInput" placeholder="Reason for warning"></textarea>
</label>
<button class="button button--primary" id="addWarningButton">Add warning</button>
<p class="muted" id="warningStatus"></p>
</div>
</section>
<div class="save-bar" id="saveBar" hidden>
<span class="save-indicator" id="dirtyIndicator">Unsaved changes</span>
<div class="button-row">
<button class="button button--ghost" id="discardChangesButton">Discard</button>
<button class="button button--primary" id="savePermissionsButton">Save changes</button>
</div>
</div>
<div class="panel skeleton-panel" id="contentSkeleton" hidden>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</main>
</div>
</div>
<div class="modal" id="copyModal" hidden>
<div class="modal__content">
<h3>Copy permissions</h3>
<p class="muted">Select another guild to copy permissions from.</p>
<select id="copyGuildSelect"></select>
<div class="button-row">
<button class="button button--ghost" id="cancelCopyButton">Cancel</button>
<button class="button button--primary" id="confirmCopyButton">Copy</button>
</div>
</div>
</div>
<script src="app.js"></script>
<script src="dashboard.js"></script>
</body>
+592 -140
View File
@@ -1,38 +1,64 @@
const state = {
guilds: [],
filteredGuilds: [],
selectedGuildId: null,
roles: [],
permissions: null,
commands: [],
botGuildIds: new Set(),
botInstalled: false,
dirty: false,
originalPermissions: null,
};
const elements = {
banner: document.getElementById('banner'),
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'),
adminRolesSelect: document.getElementById('adminRolesSelect'),
adminRolesPicker: document.getElementById('adminRolesPicker'),
adminUserInput: document.getElementById('adminUserInput'),
addAdminUserButton: document.getElementById('addAdminUserButton'),
adminUserChips: document.getElementById('adminUserChips'),
newCommandInput: document.getElementById('newCommandInput'),
addCommandButton: document.getElementById('addCommandButton'),
commandsTable: document.getElementById('commandsTable'),
commandsList: document.getElementById('commandsList'),
commandsEmpty: document.getElementById('commandsEmpty'),
clearAllCommands: document.getElementById('clearAllCommands'),
copyFromGuild: document.getElementById('copyFromGuild'),
savePermissionsButton: document.getElementById('savePermissionsButton'),
permissionsStatus: document.getElementById('permissionsStatus'),
discardChangesButton: document.getElementById('discardChangesButton'),
saveBar: document.getElementById('saveBar'),
botInactiveCallout: document.getElementById('botInactiveCallout'),
warningsUserInput: document.getElementById('warningsUserInput'),
fetchWarningsButton: document.getElementById('fetchWarningsButton'),
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 dashboardApi = window.dashboardApi;
@@ -40,23 +66,101 @@ const fetchDashboardJson = dashboardApi.fetchJson;
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
const setLoading = (isLoading) => {
document.body.classList.toggle('loading', isLoading);
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 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;
}
};
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 setError = (error) => {
if (!error) {
dashboardApi.clearErrorBanner(elements.errorBanner);
return;
}
dashboardApi.setErrorBanner(elements.errorBanner, error.message || 'Something went wrong.', error.payload || error);
};
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.guilds.length === 0) {
if (state.filteredGuilds.length === 0) {
elements.guildEmpty.hidden = false;
return;
}
elements.guildEmpty.hidden = true;
state.guilds.forEach((guild) => {
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`;
@@ -66,30 +170,117 @@ const renderGuilds = () => {
}
const name = document.createElement('span');
name.textContent = guild.name;
item.appendChild(icon);
item.appendChild(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 renderRolesSelect = () => {
elements.adminRolesSelect.innerHTML = '';
state.roles
.sort((a, b) => b.position - a.position)
.forEach((role) => {
const option = document.createElement('option');
option.value = role.id;
option.textContent = role.name;
elements.adminRolesSelect.appendChild(option);
});
const renderRolePicker = (container, roles, selectedIds, onChange, options = {}) => {
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = `multi-picker${options.disabled ? ' multi-picker--disabled' : ''}`;
if (state.permissions) {
const adminRoleIds = new Set(state.permissions.adminRoleIds || []);
Array.from(elements.adminRolesSelect.options).forEach((option) => {
option.selected = adminRoleIds.has(option.value);
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 = () => {
@@ -112,6 +303,7 @@ const renderAdminUsers = () => {
removeButton.textContent = '✕';
removeButton.addEventListener('click', () => {
state.permissions.adminUserIds = adminUserIds.filter((id) => id !== userId);
setDirty(true);
renderAdminUsers();
});
chip.appendChild(removeButton);
@@ -128,56 +320,64 @@ const ensureCommandEntry = (commandName) => {
}
};
const renderCommandsTable = () => {
const tbody = elements.commandsTable.querySelector('tbody');
tbody.innerHTML = '';
const renderCommands = () => {
elements.commandsList.innerHTML = '';
const commands = [...new Set(state.commands)].sort();
if (commands.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 4;
cell.className = 'muted';
cell.textContent = 'No commands configured yet. Add one above.';
row.appendChild(cell);
tbody.appendChild(row);
elements.commandsEmpty.hidden = false;
return;
}
elements.commandsEmpty.hidden = true;
commands.forEach((commandName) => {
ensureCommandEntry(commandName);
const commandConfig = state.permissions.commandPermissions[commandName];
const row = document.createElement('tr');
const nameCell = document.createElement('td');
nameCell.textContent = commandName;
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 roleCell = document.createElement('td');
const roleSelect = document.createElement('select');
roleSelect.multiple = true;
roleSelect.className = 'multi-select';
state.roles
.sort((a, b) => b.position - a.position)
.forEach((role) => {
const option = document.createElement('option');
option.value = role.id;
option.textContent = role.name;
option.selected = commandConfig.allowedRoleIds.includes(role.id);
roleSelect.appendChild(option);
});
roleSelect.addEventListener('change', () => {
commandConfig.allowedRoleIds = Array.from(roleSelect.selectedOptions).map((opt) => opt.value);
});
roleCell.appendChild(roleSelect);
const title = document.createElement('strong');
title.textContent = `/${commandName}`;
const userCell = document.createElement('td');
const toggleLabel = document.createElement('label');
toggleLabel.className = 'toggle';
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = commandConfig.allowedRoleIds.length > 0 || commandConfig.allowedUserIds.length > 0;
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 = 'User ID';
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';
@@ -199,6 +399,7 @@ const renderCommandsTable = () => {
remove.textContent = '✕';
remove.addEventListener('click', () => {
commandConfig.allowedUserIds = commandConfig.allowedUserIds.filter((id) => id !== userId);
setDirty(true);
renderUserChips();
});
chip.appendChild(remove);
@@ -206,46 +407,121 @@ const renderCommandsTable = () => {
});
};
addUserButton.addEventListener('click', () => {
const value = userInput.value.trim();
if (!value) {
const addUsers = () => {
const values = parseIdList(userInput.value);
if (values.length === 0) {
return;
}
if (!commandConfig.allowedUserIds.includes(value)) {
commandConfig.allowedUserIds.push(value);
renderUserChips();
}
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();
}
});
const userControls = document.createElement('div');
userControls.className = 'button-row';
userControls.appendChild(userInput);
userControls.appendChild(addUserButton);
userCell.appendChild(userControls);
userCell.appendChild(chipList);
userSection.appendChild(userLabel);
userSection.appendChild(userControls);
userSection.appendChild(chipList);
renderUserChips();
const actionCell = document.createElement('td');
const actions = document.createElement('div');
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'button button--ghost';
clearButton.textContent = 'Clear restrictions';
clearButton.textContent = 'Clear';
clearButton.addEventListener('click', () => {
commandConfig.allowedRoleIds = [];
commandConfig.allowedUserIds = [];
renderCommandsTable();
toggle.checked = false;
setDirty(true);
renderCommands();
});
actionCell.appendChild(clearButton);
actions.appendChild(clearButton);
row.appendChild(nameCell);
row.appendChild(roleCell);
row.appendChild(userCell);
row.appendChild(actionCell);
tbody.appendChild(row);
body.appendChild(roleSection);
body.appendChild(userSection);
body.appendChild(actions);
toggle.addEventListener('change', () => {
setDirty(true);
if (!toggle.checked) {
commandConfig.allowedRoleIds = [];
commandConfig.allowedUserIds = [];
setDirty(true);
}
renderCommands();
});
const controlsDisabled = !state.botInstalled || !toggle.checked;
userInput.disabled = controlsDisabled;
addUserButton.disabled = controlsDisabled;
renderRolePicker(
rolePicker,
state.roles,
commandConfig.allowedRoleIds,
(selected) => {
commandConfig.allowedRoleIds = selected;
setDirty(true);
},
{
disabled: !state.botInstalled || !toggle.checked,
emptyMessage: toggle.checked ? '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.',
},
);
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 loadSession = async () => {
const data = await fetchDashboardJson('/api/me');
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
@@ -256,33 +532,55 @@ const loadSession = async () => {
};
const loadGuilds = async () => {
const data = await fetchDashboardJson('/api/guilds');
state.guilds = data.guilds || [];
if (state.guilds.length > 0) {
state.selectedGuildId = state.guilds[0].id;
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);
}
renderGuilds();
};
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.');
}
};
const updateBotInstalled = () => {
state.botInstalled = state.selectedGuildId ? state.botGuildIds.has(state.selectedGuildId) : false;
};
const loadGuildDetails = async () => {
if (!state.selectedGuildId) {
return;
}
setLoading(true);
setLoadingDetails(true);
setError(null);
setDirty(false);
const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId);
updateBotInstalled();
updateGuildHeader(guild);
updateBotStatusControls();
try {
const [permissionsData, rolesData, commandsData] = await Promise.all([
const [permissionsData, commandsData] = await Promise.all([
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`),
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/roles`),
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/commands`),
]);
state.permissions = permissionsData.permissions || {
adminRoleIds: [],
adminUserIds: [],
commandPermissions: {},
};
state.permissions = normalizePermissions(permissionsData.permissions || {});
state.permissions.commandPermissions = state.permissions.commandPermissions || {};
state.roles = rolesData.roles || [];
state.commands = commandsData.commands || [];
Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
@@ -291,33 +589,55 @@ const loadGuildDetails = async () => {
}
});
renderRolesSelect();
renderAdminUsers();
renderCommandsTable();
elements.permissionsStatus.textContent = '';
dashboardApi.clearBanner(elements.banner);
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) {
dashboardApi.setBanner(elements.banner, error.message || 'Failed to load guild data.');
handleFetchError(error, 'Failed to load guild data.');
} finally {
setLoading(false);
setLoadingDetails(false);
}
};
const selectGuild = async (guildId) => {
if (state.dirty) {
const proceed = window.confirm('You have unsaved changes. Discard them and switch guilds?');
if (!proceed) {
return;
}
}
state.selectedGuildId = guildId;
renderGuilds();
await loadGuildDetails();
};
const addAdminUser = () => {
const value = elements.adminUserInput.value.trim();
if (!value) {
const values = parseIdList(elements.adminUserInput.value);
if (values.length === 0) {
return;
}
if (!state.permissions.adminUserIds.includes(value)) {
state.permissions.adminUserIds.push(value);
}
state.permissions.adminUserIds = Array.from(new Set([...state.permissions.adminUserIds, ...values]));
elements.adminUserInput.value = '';
setDirty(true);
renderAdminUsers();
};
@@ -327,14 +647,15 @@ const addCommand = () => {
return;
}
if (!COMMAND_NAME_REGEX.test(value)) {
dashboardApi.setBanner(elements.banner, 'Command name must be 1-32 characters and use letters, numbers, - or _.');
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 = '';
renderCommandsTable();
renderCommands();
};
const savePermissions = async () => {
@@ -342,38 +663,38 @@ const savePermissions = async () => {
return;
}
const adminRoleIds = Array.from(elements.adminRolesSelect.selectedOptions).map((option) => option.value);
state.permissions.adminRoleIds = adminRoleIds;
const payload = {
adminRoleIds: state.permissions.adminRoleIds,
adminUserIds: state.permissions.adminUserIds,
commandPermissions: state.permissions.commandPermissions,
};
setLoading(true);
setLoadingDetails(true);
try {
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
method: 'PATCH',
body: JSON.stringify(payload),
body: JSON.stringify(state.permissions),
});
state.permissions = data.permissions;
elements.permissionsStatus.textContent = 'Permissions saved successfully.';
dashboardApi.setBanner(elements.banner, 'Permissions updated successfully.', 'success');
renderRolesSelect();
renderAdminUsers();
renderCommandsTable();
state.permissions = normalizePermissions(data.permissions || {});
state.originalPermissions = clonePermissions(state.permissions);
setDirty(false);
dashboardApi.showToast('Permissions saved.', 'success');
renderPanels();
} catch (error) {
dashboardApi.setBanner(elements.banner, error.message || 'Failed to save permissions.');
handleFetchError(error, 'Failed to save permissions.');
} finally {
setLoading(false);
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';
};
@@ -381,10 +702,10 @@ const switchTab = (tabName) => {
const fetchWarnings = async () => {
const userId = elements.warningsUserInput.value.trim();
if (!userId) {
dashboardApi.setBanner(elements.banner, 'Enter a user ID to fetch warnings.');
setError({message: 'Enter a user ID to fetch warnings.'});
return;
}
setLoading(true);
elements.warningsSpinner.hidden = false;
try {
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
const warnings = data.warnings || [];
@@ -395,19 +716,50 @@ const fetchWarnings = async () => {
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';
item.textContent = `${warning.reason} (Moderator: ${warning.moderatorId || 'Unknown'}) — ${dateText}`;
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);
});
}
dashboardApi.clearBanner(elements.banner);
setError(null);
} catch (error) {
dashboardApi.setBanner(elements.banner, error.message || 'Failed to fetch warnings.');
handleFetchError(error, 'Failed to fetch warnings.');
} finally {
setLoading(false);
elements.warningsSpinner.hidden = true;
}
};
const updateWarningCount = () => {
const length = elements.warningReasonInput.value.length;
elements.warningCharCount.textContent = `${length}/200`;
};
const addWarning = async () => {
const userId = elements.newWarningUserInput.value.trim();
const reason = elements.warningReasonInput.value.trim();
@@ -415,8 +767,12 @@ const addWarning = async () => {
elements.warningStatus.textContent = 'User ID and reason are required.';
return;
}
if (reason.length > 200) {
elements.warningStatus.textContent = 'Reason must be under 200 characters.';
return;
}
setLoading(true);
elements.warningStatus.textContent = '';
try {
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
method: 'POST',
@@ -424,23 +780,79 @@ const addWarning = async () => {
});
elements.warningStatus.textContent = `Warning added. Total warnings: ${data.warnings?.length || 0}`;
elements.warningReasonInput.value = '';
dashboardApi.clearBanner(elements.banner);
updateWarningCount();
dashboardApi.showToast('Warning saved.', 'success');
} catch (error) {
dashboardApi.setBanner(elements.banner, error.message || 'Failed to add warning.');
handleFetchError(error, 'Failed to add warning.');
}
};
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 {
setLoading(false);
closeCopyModal();
}
};
const initDashboard = async () => {
try {
await loadSession();
await loadGuilds();
await Promise.all([loadGuilds(), loadBotGuilds()]);
renderGuilds();
if (state.selectedGuildId) {
await loadGuildDetails();
}
} catch (error) {
dashboardApi.setBanner(elements.banner, 'Please log in to access the dashboard.');
setError({message: 'Please log in to access the dashboard.'});
window.location.href = '/';
return;
}
@@ -455,10 +867,50 @@ const initDashboard = async () => {
});
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.addWarningButton.addEventListener('click', addWarning);
elements.warningReasonInput.addEventListener('input', updateWarningCount);
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: [],
};
});
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', () => {
+484 -77
View File
@@ -10,6 +10,7 @@
--primary-dark: #4752c4;
--success: #3ba55d;
--danger: #ed4245;
--warning: #f6c343;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@@ -33,58 +34,95 @@ button {
font: inherit;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px 60px;
input,
select,
textarea {
font: inherit;
}
.topbar {
.app-shell {
max-width: 1280px;
margin: 0 auto;
padding: 24px 24px 60px;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
margin-bottom: 24px;
margin-bottom: 20px;
}
.topbar__user {
.navbar__brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 44px;
height: 44px;
border-radius: 14px;
background: var(--primary);
display: grid;
place-items: center;
font-weight: 700;
}
.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(237, 66, 69, 0.4);
background: rgba(237, 66, 69, 0.12);
margin-bottom: 16px;
}
.toast-container {
position: fixed;
top: 20px;
right: 24px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 12px;
z-index: 50;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(88, 101, 242, 0.2);
color: #dfe1ff;
font-size: 0.85rem;
}
.banner {
.toast {
opacity: 0;
transform: translateY(-8px);
padding: 12px 16px;
border-radius: 12px;
background: var(--panel);
border: 1px solid var(--border);
background: #22283a;
margin-bottom: 16px;
display: none;
transition: all 0.2s ease;
}
.banner--error {
border-color: rgba(237, 66, 69, 0.4);
background: rgba(237, 66, 69, 0.15);
.toast--visible {
opacity: 1;
transform: translateY(0);
}
.banner--success {
border-color: rgba(59, 165, 93, 0.4);
background: rgba(59, 165, 93, 0.15);
.toast--success {
border-color: rgba(59, 165, 93, 0.5);
}
.toast--error {
border-color: rgba(237, 66, 69, 0.5);
}
.layout {
@@ -93,22 +131,38 @@ button {
gap: 24px;
}
.panel {
.sidebar {
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
padding: 16px;
}
.guild-list {
.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: #131826;
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: var(--card);
@@ -117,6 +171,13 @@ button {
cursor: pointer;
}
.guild-item__info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.guild-item img {
width: 36px;
height: 36px;
@@ -124,20 +185,103 @@ button {
background: var(--border);
}
.guild-item span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-item.active {
border-color: var(--primary);
}
.section {
margin-bottom: 24px;
.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);
}
.section__header {
.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: 16px;
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
}
.guild-header__info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
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 {
@@ -147,7 +291,7 @@ button {
background: var(--panel);
border-radius: 12px;
border: 1px solid var(--border);
margin-bottom: 16px;
width: fit-content;
}
.tab {
@@ -164,11 +308,43 @@ button {
color: white;
}
.panel {
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
padding: 16px;
}
.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(246, 195, 67, 0.4);
background: rgba(246, 195, 67, 0.12);
margin-bottom: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: 16px;
}
.field label {
font-size: 0.9rem;
color: var(--muted);
}
.field input,
@@ -182,12 +358,76 @@ button {
}
.field textarea {
min-height: 90px;
min-height: 100px;
resize: vertical;
}
.multi-select {
min-height: 120px;
.field__meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.input-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.multi-picker {
background: #131826;
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: #0f1320;
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 {
@@ -219,71 +459,238 @@ button {
flex-wrap: wrap;
}
.chip-list {
.command-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
flex-direction: column;
gap: 16px;
}
.chip {
.command-card {
background: var(--card);
border-radius: 14px;
border: 1px solid var(--border);
padding: 14px;
}
.command-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.command-card__body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-top: 12px;
}
.toggle {
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);
.toggle input {
width: 40px;
height: 20px;
appearance: none;
background: var(--border);
border-radius: 999px;
position: relative;
cursor: pointer;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
.toggle input::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 999px;
background: white;
top: 2px;
left: 2px;
transition: transform 0.2s ease;
}
.table th,
.table td {
text-align: left;
.toggle input:checked {
background: var(--primary);
}
.toggle input:checked::after {
transform: translateX(20px);
}
.warning-item {
background: var(--card);
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
margin-bottom: 10px;
}
.table th {
.warning-item__meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
}
.muted {
color: var(--muted);
font-size: 0.9rem;
.warnings-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 16px;
}
.loading {
opacity: 0.7;
pointer-events: none;
.warnings-pane {
background: var(--card);
border-radius: 12px;
border: 1px solid var(--border);
padding: 14px;
}
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.save-bar {
position: sticky;
bottom: 16px;
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.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: 24px;
padding: 20px;
border-radius: 12px;
border: 1px dashed var(--border);
background: #151a27;
color: var(--muted);
}
@media (max-width: 960px) {
.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, #1a1f2d 25%, #232a3b 37%, #1a1f2d 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;
}
.modal__content {
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
padding: 20px;
width: min(420px, 90vw);
display: flex;
flex-direction: column;
gap: 16px;
}
.modal__content select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #131826;
color: var(--text);
}
.command-card--disabled,
.panel--disabled,
.multi-picker--disabled {
opacity: 0.6;
pointer-events: none;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.warnings-layout {
grid-template-columns: 1fr;
}
.save-bar {
position: static;
}
}