Merge pull request #19 from Fretuks/improve-dashboard-gui-and-invite-bot-handling
Improve dashboard UX and handle bot invite flow
This commit is contained in:
+96
-4
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
+146
-79
@@ -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>
|
||||
<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>
|
||||
<span class="muted" id="sessionExpiry"></span>
|
||||
<p class="muted" id="sessionExpiry"></p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="button" href="/">Home</a>
|
||||
<button class="button button--ghost" id="logoutButton">Log out</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
<section class="panel" id="settingsPanel">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<h2>Admin access</h2>
|
||||
<p class="muted">Choose who can edit the bot configuration.</p>
|
||||
<p class="muted">Control who can edit bot configuration and permissions.</p>
|
||||
</div>
|
||||
</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 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>
|
||||
<div class="chip-list" id="adminUserChips"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<section class="panel" id="permissionsPanel" hidden>
|
||||
<div class="panel__header panel__header--split">
|
||||
<div>
|
||||
<h2>Command permissions</h2>
|
||||
<p class="muted">Configure who can run each command. Empty restrictions allow everyone.</p>
|
||||
<p class="muted">Restrict commands to roles or specific users.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<input type="text" id="newCommandInput" placeholder="new-command" />
|
||||
<button class="button" id="addCommandButton">Add command</button>
|
||||
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
|
||||
<button class="button" id="copyFromGuild">Copy from another guild</button>
|
||||
</div>
|
||||
</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 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 class="button-row">
|
||||
<button class="button button--primary" id="savePermissionsButton">Save permissions</button>
|
||||
<span class="muted" id="permissionsStatus"></span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<div>
|
||||
<h2>Add warning</h2>
|
||||
<p class="muted">Record a new warning and sync strikes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>User ID</span>
|
||||
<div class="divider"></div>
|
||||
<h3>Add warning</h3>
|
||||
<div class="field">
|
||||
<label for="newWarningUserInput">User ID</label>
|
||||
<input type="text" id="newWarningUserInput" placeholder="123456789" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reason</span>
|
||||
<textarea id="warningReasonInput" placeholder="Reason for warning"></textarea>
|
||||
</label>
|
||||
</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>
|
||||
<p class="muted" id="warningStatus"></p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+588
-136
@@ -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,44 +407,119 @@ 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 () => {
|
||||
@@ -256,33 +532,55 @@ const loadSession = async () => {
|
||||
};
|
||||
|
||||
const loadGuilds = async () => {
|
||||
setLoadingGuilds(true);
|
||||
try {
|
||||
const data = await fetchDashboardJson('/api/guilds');
|
||||
state.guilds = data.guilds || [];
|
||||
if (state.guilds.length > 0) {
|
||||
state.filteredGuilds = [...state.guilds];
|
||||
if (state.guilds.length > 0 && !state.selectedGuildId) {
|
||||
state.selectedGuildId = state.guilds[0].id;
|
||||
}
|
||||
renderGuilds();
|
||||
} finally {
|
||||
setLoadingGuilds(false);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
dashboardApi.setBanner(elements.banner, error.message || 'Failed to load guild data.');
|
||||
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 {
|
||||
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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user