Improve dashboard UX and bot invite flow
This commit is contained in:
+96
-4
@@ -38,6 +38,10 @@ const DISCORD_BOT_TOKEN =
|
|||||||
process.env.DISCORD_BOT_TOKEN ||
|
process.env.DISCORD_BOT_TOKEN ||
|
||||||
config.discordBotToken ||
|
config.discordBotToken ||
|
||||||
config.token;
|
config.token;
|
||||||
|
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
|
||||||
|
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
|
||||||
|
10,
|
||||||
|
);
|
||||||
const DASHBOARD_CLIENT_URL =
|
const DASHBOARD_CLIENT_URL =
|
||||||
process.env.DASHBOARD_CLIENT_URL ||
|
process.env.DASHBOARD_CLIENT_URL ||
|
||||||
config.dashboardClientUrl ||
|
config.dashboardClientUrl ||
|
||||||
@@ -59,9 +63,14 @@ const DISCORD_API_BASE = 'https://discord.com/api';
|
|||||||
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
|
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
|
||||||
const COMMANDS_PATH = path.join(__dirname, 'commands.json');
|
const COMMANDS_PATH = path.join(__dirname, 'commands.json');
|
||||||
const GUILD_CACHE_TTL_MS = 5 * 60 * 1000;
|
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 BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.';
|
||||||
|
|
||||||
const sessionStore = new Map();
|
const sessionStore = new Map();
|
||||||
|
const botGuildCache = {
|
||||||
|
guildIds: null,
|
||||||
|
fetchedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const parseCookies = (headerValue) => {
|
const parseCookies = (headerValue) => {
|
||||||
const cookies = {};
|
const cookies = {};
|
||||||
@@ -153,12 +162,12 @@ const getDiscordStatusCode = (error) => {
|
|||||||
const sendDiscordApiError = (res, error, fallbackMessage) => {
|
const sendDiscordApiError = (res, error, fallbackMessage) => {
|
||||||
const status = getDiscordStatusCode(error);
|
const status = getDiscordStatusCode(error);
|
||||||
if (status === 401 || status === 403) {
|
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) {
|
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) => {
|
const buildAuthorizationUrl = (state) => {
|
||||||
@@ -226,6 +235,55 @@ const fetchDiscordGuildRoles = async (guildId) => {
|
|||||||
return response.data;
|
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) => {
|
const cacheSessionGuilds = (session, guilds) => {
|
||||||
session.guilds = guilds;
|
session.guilds = guilds;
|
||||||
session.guildsFetchedAt = Date.now();
|
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(
|
app.get(
|
||||||
'/api/guilds/:guildId/permissions',
|
'/api/guilds/:guildId/permissions',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
@@ -482,8 +566,16 @@ const createDashboardApp = () => {
|
|||||||
const roles = await fetchDiscordGuildRoles(req.params.guildId);
|
const roles = await fetchDiscordGuildRoles(req.params.guildId);
|
||||||
return res.json({roles});
|
return res.json({roles});
|
||||||
} catch (error) {
|
} 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) {
|
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');
|
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 fetchJson = async (url, options = {}) => {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -9,39 +25,76 @@ const fetchJson = async (url, options = {}) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
throw await createFetchError(response);
|
||||||
const message = body.error || `Request failed with status ${response.status}`;
|
|
||||||
const error = new Error(message);
|
|
||||||
error.details = body.details;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json().catch(() => ({}));
|
return response.json().catch(() => ({}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBanner = (bannerEl, message, type = 'error') => {
|
const showToast = (message, type = 'success') => {
|
||||||
if (!bannerEl) {
|
const container = document.getElementById('toastContainer');
|
||||||
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bannerEl.textContent = message;
|
const toast = document.createElement('div');
|
||||||
bannerEl.classList.remove('banner--error', 'banner--success');
|
toast.className = `toast toast--${type}`;
|
||||||
bannerEl.classList.add(type === 'success' ? 'banner--success' : 'banner--error');
|
toast.textContent = message;
|
||||||
bannerEl.style.display = 'block';
|
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) {
|
if (!bannerEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bannerEl.textContent = '';
|
const messageEl = bannerEl.querySelector('[data-role="error-message"]');
|
||||||
bannerEl.style.display = 'none';
|
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) => {
|
const formatUserDisplay = (user) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return 'Not signed in';
|
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 () => {
|
const initIndexPage = async () => {
|
||||||
@@ -61,13 +114,19 @@ const initIndexPage = async () => {
|
|||||||
loginButton.hidden = true;
|
loginButton.hidden = true;
|
||||||
dashboardButton.hidden = false;
|
dashboardButton.hidden = false;
|
||||||
logoutButton.hidden = false;
|
logoutButton.hidden = false;
|
||||||
clearBanner(banner);
|
if (banner) {
|
||||||
|
banner.textContent = '';
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sessionStatus.textContent = 'You are not signed in yet.';
|
sessionStatus.textContent = 'You are not signed in yet.';
|
||||||
loginButton.hidden = false;
|
loginButton.hidden = false;
|
||||||
dashboardButton.hidden = true;
|
dashboardButton.hidden = true;
|
||||||
logoutButton.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 () => {
|
logoutButton?.addEventListener('click', async () => {
|
||||||
@@ -78,7 +137,10 @@ const initIndexPage = async () => {
|
|||||||
dashboardButton.hidden = true;
|
dashboardButton.hidden = true;
|
||||||
logoutButton.hidden = true;
|
logoutButton.hidden = true;
|
||||||
} catch (error) {
|
} 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 = {
|
window.dashboardApi = {
|
||||||
fetchJson,
|
fetchJson,
|
||||||
setBanner,
|
showToast,
|
||||||
clearBanner,
|
setErrorBanner,
|
||||||
|
clearErrorBanner,
|
||||||
formatUserDisplay,
|
formatUserDisplay,
|
||||||
|
debounce,
|
||||||
};
|
};
|
||||||
|
|||||||
+162
-95
@@ -7,134 +7,201 @@
|
|||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="navbar">
|
||||||
<div class="topbar__user">
|
<div class="navbar__brand">
|
||||||
<span class="badge">Guild dashboard</span>
|
<div class="brand-mark">DB</div>
|
||||||
<strong id="userDisplay">Loading session...</strong>
|
<div>
|
||||||
<span class="muted" id="sessionExpiry"></span>
|
<p class="muted">Discord Bot Dashboard</p>
|
||||||
|
<strong id="userDisplay">Loading session...</strong>
|
||||||
|
<p class="muted" id="sessionExpiry"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="navbar__actions">
|
||||||
<a class="button" href="/">Home</a>
|
<a class="button button--ghost" href="/">Home</a>
|
||||||
<button class="button button--ghost" id="logoutButton">Log out</button>
|
<button class="button button--primary" id="logoutButton">Log out</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="layout">
|
||||||
<aside class="panel">
|
<aside class="sidebar">
|
||||||
<div class="section">
|
<div class="sidebar__header">
|
||||||
<div class="section__header">
|
<div>
|
||||||
<h2>Manageable guilds</h2>
|
<h2>Guilds</h2>
|
||||||
</div>
|
<p class="muted">Pick a server to manage.</p>
|
||||||
<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.
|
|
||||||
</div>
|
</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>
|
</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">
|
<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>
|
<button class="tab" data-tab="warnings">Warnings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel" id="permissionsPanel">
|
<section class="panel" id="settingsPanel">
|
||||||
<div class="section">
|
<div class="panel__header">
|
||||||
<div class="section__header">
|
<div>
|
||||||
<div>
|
<h2>Admin access</h2>
|
||||||
<h2>Admin access</h2>
|
<p class="muted">Control who can edit bot configuration and permissions.</p>
|
||||||
<p class="muted">Choose who can edit the bot configuration.</p>
|
</div>
|
||||||
</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>
|
</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 class="chip-list" id="adminUserChips"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="section">
|
<section class="panel" id="permissionsPanel" hidden>
|
||||||
<div class="section__header">
|
<div class="panel__header panel__header--split">
|
||||||
<div>
|
<div>
|
||||||
<h2>Command permissions</h2>
|
<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>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<input type="text" id="newCommandInput" placeholder="new-command" />
|
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
|
||||||
<button class="button" id="addCommandButton">Add command</button>
|
<button class="button" id="copyFromGuild">Copy from another guild</button>
|
||||||
</div>
|
|
||||||
</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>
|
</div>
|
||||||
|
<div class="field">
|
||||||
<div class="button-row">
|
<span>
|
||||||
<button class="button button--primary" id="savePermissionsButton">Save permissions</button>
|
Add command
|
||||||
<span class="muted" id="permissionsStatus"></span>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="warningsPanel" hidden>
|
<section class="panel" id="warningsPanel" hidden>
|
||||||
<div class="section">
|
<div class="warnings-layout">
|
||||||
<div class="section__header">
|
<div class="warnings-pane">
|
||||||
<div>
|
<h2>User lookup</h2>
|
||||||
<h2>Warnings lookup</h2>
|
<p class="muted">Fetch warnings for a user ID.</p>
|
||||||
<p class="muted">Fetch recent warnings for a user.</p>
|
<div class="field">
|
||||||
</div>
|
<label for="warningsUserInput">User ID</label>
|
||||||
</div>
|
|
||||||
<label class="field">
|
|
||||||
<span>User ID</span>
|
|
||||||
<div class="button-row">
|
|
||||||
<input type="text" id="warningsUserInput" placeholder="123456789" />
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</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="app.js"></script>
|
||||||
<script src="dashboard.js"></script>
|
<script src="dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+592
-140
@@ -1,38 +1,64 @@
|
|||||||
const state = {
|
const state = {
|
||||||
guilds: [],
|
guilds: [],
|
||||||
|
filteredGuilds: [],
|
||||||
selectedGuildId: null,
|
selectedGuildId: null,
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: null,
|
permissions: null,
|
||||||
commands: [],
|
commands: [],
|
||||||
|
botGuildIds: new Set(),
|
||||||
|
botInstalled: false,
|
||||||
|
dirty: false,
|
||||||
|
originalPermissions: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
banner: document.getElementById('banner'),
|
errorBanner: document.getElementById('errorBanner'),
|
||||||
userDisplay: document.getElementById('userDisplay'),
|
userDisplay: document.getElementById('userDisplay'),
|
||||||
sessionExpiry: document.getElementById('sessionExpiry'),
|
sessionExpiry: document.getElementById('sessionExpiry'),
|
||||||
logoutButton: document.getElementById('logoutButton'),
|
logoutButton: document.getElementById('logoutButton'),
|
||||||
guildList: document.getElementById('guildList'),
|
guildList: document.getElementById('guildList'),
|
||||||
|
guildListSkeleton: document.getElementById('guildListSkeleton'),
|
||||||
guildEmpty: document.getElementById('guildEmpty'),
|
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'),
|
tabs: document.querySelectorAll('.tab'),
|
||||||
|
settingsPanel: document.getElementById('settingsPanel'),
|
||||||
permissionsPanel: document.getElementById('permissionsPanel'),
|
permissionsPanel: document.getElementById('permissionsPanel'),
|
||||||
warningsPanel: document.getElementById('warningsPanel'),
|
warningsPanel: document.getElementById('warningsPanel'),
|
||||||
adminRolesSelect: document.getElementById('adminRolesSelect'),
|
adminRolesPicker: document.getElementById('adminRolesPicker'),
|
||||||
adminUserInput: document.getElementById('adminUserInput'),
|
adminUserInput: document.getElementById('adminUserInput'),
|
||||||
addAdminUserButton: document.getElementById('addAdminUserButton'),
|
addAdminUserButton: document.getElementById('addAdminUserButton'),
|
||||||
adminUserChips: document.getElementById('adminUserChips'),
|
adminUserChips: document.getElementById('adminUserChips'),
|
||||||
newCommandInput: document.getElementById('newCommandInput'),
|
newCommandInput: document.getElementById('newCommandInput'),
|
||||||
addCommandButton: document.getElementById('addCommandButton'),
|
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'),
|
savePermissionsButton: document.getElementById('savePermissionsButton'),
|
||||||
permissionsStatus: document.getElementById('permissionsStatus'),
|
discardChangesButton: document.getElementById('discardChangesButton'),
|
||||||
|
saveBar: document.getElementById('saveBar'),
|
||||||
|
botInactiveCallout: document.getElementById('botInactiveCallout'),
|
||||||
warningsUserInput: document.getElementById('warningsUserInput'),
|
warningsUserInput: document.getElementById('warningsUserInput'),
|
||||||
fetchWarningsButton: document.getElementById('fetchWarningsButton'),
|
fetchWarningsButton: document.getElementById('fetchWarningsButton'),
|
||||||
warningsList: document.getElementById('warningsList'),
|
warningsList: document.getElementById('warningsList'),
|
||||||
warningsEmpty: document.getElementById('warningsEmpty'),
|
warningsEmpty: document.getElementById('warningsEmpty'),
|
||||||
|
warningsSpinner: document.getElementById('warningsSpinner'),
|
||||||
newWarningUserInput: document.getElementById('newWarningUserInput'),
|
newWarningUserInput: document.getElementById('newWarningUserInput'),
|
||||||
warningReasonInput: document.getElementById('warningReasonInput'),
|
warningReasonInput: document.getElementById('warningReasonInput'),
|
||||||
|
warningCharCount: document.getElementById('warningCharCount'),
|
||||||
addWarningButton: document.getElementById('addWarningButton'),
|
addWarningButton: document.getElementById('addWarningButton'),
|
||||||
warningStatus: document.getElementById('warningStatus'),
|
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;
|
const dashboardApi = window.dashboardApi;
|
||||||
@@ -40,23 +66,101 @@ const fetchDashboardJson = dashboardApi.fetchJson;
|
|||||||
|
|
||||||
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
|
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
|
||||||
|
|
||||||
const setLoading = (isLoading) => {
|
const clonePermissions = (permissions) => JSON.parse(JSON.stringify(permissions || {}));
|
||||||
document.body.classList.toggle('loading', isLoading);
|
|
||||||
|
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 = () => {
|
const renderGuilds = () => {
|
||||||
elements.guildList.innerHTML = '';
|
elements.guildList.innerHTML = '';
|
||||||
if (state.guilds.length === 0) {
|
if (state.filteredGuilds.length === 0) {
|
||||||
elements.guildEmpty.hidden = false;
|
elements.guildEmpty.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.guildEmpty.hidden = true;
|
elements.guildEmpty.hidden = true;
|
||||||
state.guilds.forEach((guild) => {
|
state.filteredGuilds.forEach((guild) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
item.className = `guild-item${state.selectedGuildId === guild.id ? ' active' : ''}`;
|
item.className = `guild-item${state.selectedGuildId === guild.id ? ' active' : ''}`;
|
||||||
item.dataset.guildId = guild.id;
|
item.dataset.guildId = guild.id;
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'guild-item__info';
|
||||||
const icon = document.createElement('img');
|
const icon = document.createElement('img');
|
||||||
if (guild.icon) {
|
if (guild.icon) {
|
||||||
icon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
|
icon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
|
||||||
@@ -66,30 +170,117 @@ const renderGuilds = () => {
|
|||||||
}
|
}
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.textContent = guild.name;
|
name.textContent = guild.name;
|
||||||
item.appendChild(icon);
|
info.appendChild(icon);
|
||||||
item.appendChild(name);
|
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));
|
item.addEventListener('click', () => selectGuild(guild.id));
|
||||||
elements.guildList.appendChild(item);
|
elements.guildList.appendChild(item);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRolesSelect = () => {
|
const renderRolePicker = (container, roles, selectedIds, onChange, options = {}) => {
|
||||||
elements.adminRolesSelect.innerHTML = '';
|
container.innerHTML = '';
|
||||||
state.roles
|
const wrapper = document.createElement('div');
|
||||||
.sort((a, b) => b.position - a.position)
|
wrapper.className = `multi-picker${options.disabled ? ' multi-picker--disabled' : ''}`;
|
||||||
.forEach((role) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = role.id;
|
|
||||||
option.textContent = role.name;
|
|
||||||
elements.adminRolesSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.permissions) {
|
const search = document.createElement('input');
|
||||||
const adminRoleIds = new Set(state.permissions.adminRoleIds || []);
|
search.type = 'text';
|
||||||
Array.from(elements.adminRolesSelect.options).forEach((option) => {
|
search.className = 'multi-picker__search';
|
||||||
option.selected = adminRoleIds.has(option.value);
|
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 = () => {
|
const renderAdminUsers = () => {
|
||||||
@@ -112,6 +303,7 @@ const renderAdminUsers = () => {
|
|||||||
removeButton.textContent = '✕';
|
removeButton.textContent = '✕';
|
||||||
removeButton.addEventListener('click', () => {
|
removeButton.addEventListener('click', () => {
|
||||||
state.permissions.adminUserIds = adminUserIds.filter((id) => id !== userId);
|
state.permissions.adminUserIds = adminUserIds.filter((id) => id !== userId);
|
||||||
|
setDirty(true);
|
||||||
renderAdminUsers();
|
renderAdminUsers();
|
||||||
});
|
});
|
||||||
chip.appendChild(removeButton);
|
chip.appendChild(removeButton);
|
||||||
@@ -128,56 +320,64 @@ const ensureCommandEntry = (commandName) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCommandsTable = () => {
|
const renderCommands = () => {
|
||||||
const tbody = elements.commandsTable.querySelector('tbody');
|
elements.commandsList.innerHTML = '';
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
const commands = [...new Set(state.commands)].sort();
|
const commands = [...new Set(state.commands)].sort();
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
const row = document.createElement('tr');
|
elements.commandsEmpty.hidden = false;
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
elements.commandsEmpty.hidden = true;
|
||||||
|
|
||||||
commands.forEach((commandName) => {
|
commands.forEach((commandName) => {
|
||||||
ensureCommandEntry(commandName);
|
ensureCommandEntry(commandName);
|
||||||
const commandConfig = state.permissions.commandPermissions[commandName];
|
const commandConfig = state.permissions.commandPermissions[commandName];
|
||||||
const row = document.createElement('tr');
|
|
||||||
|
|
||||||
const nameCell = document.createElement('td');
|
const card = document.createElement('div');
|
||||||
nameCell.textContent = commandName;
|
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 title = document.createElement('strong');
|
||||||
const roleSelect = document.createElement('select');
|
title.textContent = `/${commandName}`;
|
||||||
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 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');
|
const userInput = document.createElement('input');
|
||||||
userInput.type = 'text';
|
userInput.type = 'text';
|
||||||
userInput.placeholder = 'User ID';
|
userInput.placeholder = 'Paste user IDs';
|
||||||
const addUserButton = document.createElement('button');
|
const addUserButton = document.createElement('button');
|
||||||
addUserButton.type = 'button';
|
addUserButton.type = 'button';
|
||||||
addUserButton.className = 'button';
|
addUserButton.className = 'button';
|
||||||
addUserButton.textContent = 'Add';
|
addUserButton.textContent = 'Add';
|
||||||
|
const userControls = document.createElement('div');
|
||||||
|
userControls.className = 'input-row';
|
||||||
|
userControls.appendChild(userInput);
|
||||||
|
userControls.appendChild(addUserButton);
|
||||||
const chipList = document.createElement('div');
|
const chipList = document.createElement('div');
|
||||||
chipList.className = 'chip-list';
|
chipList.className = 'chip-list';
|
||||||
|
|
||||||
@@ -199,6 +399,7 @@ const renderCommandsTable = () => {
|
|||||||
remove.textContent = '✕';
|
remove.textContent = '✕';
|
||||||
remove.addEventListener('click', () => {
|
remove.addEventListener('click', () => {
|
||||||
commandConfig.allowedUserIds = commandConfig.allowedUserIds.filter((id) => id !== userId);
|
commandConfig.allowedUserIds = commandConfig.allowedUserIds.filter((id) => id !== userId);
|
||||||
|
setDirty(true);
|
||||||
renderUserChips();
|
renderUserChips();
|
||||||
});
|
});
|
||||||
chip.appendChild(remove);
|
chip.appendChild(remove);
|
||||||
@@ -206,46 +407,121 @@ const renderCommandsTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
addUserButton.addEventListener('click', () => {
|
const addUsers = () => {
|
||||||
const value = userInput.value.trim();
|
const values = parseIdList(userInput.value);
|
||||||
if (!value) {
|
if (values.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!commandConfig.allowedUserIds.includes(value)) {
|
const merged = Array.from(new Set([...commandConfig.allowedUserIds, ...values]));
|
||||||
commandConfig.allowedUserIds.push(value);
|
commandConfig.allowedUserIds = merged;
|
||||||
renderUserChips();
|
setDirty(true);
|
||||||
}
|
|
||||||
userInput.value = '';
|
userInput.value = '';
|
||||||
|
renderUserChips();
|
||||||
|
};
|
||||||
|
|
||||||
|
addUserButton.addEventListener('click', addUsers);
|
||||||
|
userInput.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
addUsers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const userControls = document.createElement('div');
|
userSection.appendChild(userLabel);
|
||||||
userControls.className = 'button-row';
|
userSection.appendChild(userControls);
|
||||||
userControls.appendChild(userInput);
|
userSection.appendChild(chipList);
|
||||||
userControls.appendChild(addUserButton);
|
|
||||||
userCell.appendChild(userControls);
|
|
||||||
userCell.appendChild(chipList);
|
|
||||||
renderUserChips();
|
renderUserChips();
|
||||||
|
|
||||||
const actionCell = document.createElement('td');
|
const actions = document.createElement('div');
|
||||||
const clearButton = document.createElement('button');
|
const clearButton = document.createElement('button');
|
||||||
clearButton.type = 'button';
|
clearButton.type = 'button';
|
||||||
clearButton.className = 'button button--ghost';
|
clearButton.className = 'button button--ghost';
|
||||||
clearButton.textContent = 'Clear restrictions';
|
clearButton.textContent = 'Clear';
|
||||||
clearButton.addEventListener('click', () => {
|
clearButton.addEventListener('click', () => {
|
||||||
commandConfig.allowedRoleIds = [];
|
commandConfig.allowedRoleIds = [];
|
||||||
commandConfig.allowedUserIds = [];
|
commandConfig.allowedUserIds = [];
|
||||||
renderCommandsTable();
|
toggle.checked = false;
|
||||||
|
setDirty(true);
|
||||||
|
renderCommands();
|
||||||
});
|
});
|
||||||
actionCell.appendChild(clearButton);
|
actions.appendChild(clearButton);
|
||||||
|
|
||||||
row.appendChild(nameCell);
|
body.appendChild(roleSection);
|
||||||
row.appendChild(roleCell);
|
body.appendChild(userSection);
|
||||||
row.appendChild(userCell);
|
body.appendChild(actions);
|
||||||
row.appendChild(actionCell);
|
|
||||||
tbody.appendChild(row);
|
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 loadSession = async () => {
|
||||||
const data = await fetchDashboardJson('/api/me');
|
const data = await fetchDashboardJson('/api/me');
|
||||||
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
|
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
|
||||||
@@ -256,33 +532,55 @@ const loadSession = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadGuilds = async () => {
|
const loadGuilds = async () => {
|
||||||
const data = await fetchDashboardJson('/api/guilds');
|
setLoadingGuilds(true);
|
||||||
state.guilds = data.guilds || [];
|
try {
|
||||||
if (state.guilds.length > 0) {
|
const data = await fetchDashboardJson('/api/guilds');
|
||||||
state.selectedGuildId = state.guilds[0].id;
|
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 () => {
|
const loadGuildDetails = async () => {
|
||||||
if (!state.selectedGuildId) {
|
if (!state.selectedGuildId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoadingDetails(true);
|
||||||
|
setError(null);
|
||||||
|
setDirty(false);
|
||||||
|
|
||||||
|
const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId);
|
||||||
|
updateBotInstalled();
|
||||||
|
updateGuildHeader(guild);
|
||||||
|
updateBotStatusControls();
|
||||||
|
|
||||||
try {
|
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}/permissions`),
|
||||||
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/roles`),
|
|
||||||
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/commands`),
|
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/commands`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
state.permissions = permissionsData.permissions || {
|
state.permissions = normalizePermissions(permissionsData.permissions || {});
|
||||||
adminRoleIds: [],
|
|
||||||
adminUserIds: [],
|
|
||||||
commandPermissions: {},
|
|
||||||
};
|
|
||||||
state.permissions.commandPermissions = state.permissions.commandPermissions || {};
|
state.permissions.commandPermissions = state.permissions.commandPermissions || {};
|
||||||
state.roles = rolesData.roles || [];
|
|
||||||
state.commands = commandsData.commands || [];
|
state.commands = commandsData.commands || [];
|
||||||
|
|
||||||
Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
|
Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
|
||||||
@@ -291,33 +589,55 @@ const loadGuildDetails = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderRolesSelect();
|
if (state.botInstalled) {
|
||||||
renderAdminUsers();
|
try {
|
||||||
renderCommandsTable();
|
const rolesData = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/roles`);
|
||||||
elements.permissionsStatus.textContent = '';
|
state.roles = rolesData.roles || [];
|
||||||
dashboardApi.clearBanner(elements.banner);
|
} 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) {
|
} catch (error) {
|
||||||
dashboardApi.setBanner(elements.banner, error.message || 'Failed to load guild data.');
|
handleFetchError(error, 'Failed to load guild data.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoadingDetails(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectGuild = async (guildId) => {
|
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;
|
state.selectedGuildId = guildId;
|
||||||
renderGuilds();
|
renderGuilds();
|
||||||
await loadGuildDetails();
|
await loadGuildDetails();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addAdminUser = () => {
|
const addAdminUser = () => {
|
||||||
const value = elements.adminUserInput.value.trim();
|
const values = parseIdList(elements.adminUserInput.value);
|
||||||
if (!value) {
|
if (values.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.permissions.adminUserIds.includes(value)) {
|
state.permissions.adminUserIds = Array.from(new Set([...state.permissions.adminUserIds, ...values]));
|
||||||
state.permissions.adminUserIds.push(value);
|
|
||||||
}
|
|
||||||
elements.adminUserInput.value = '';
|
elements.adminUserInput.value = '';
|
||||||
|
setDirty(true);
|
||||||
renderAdminUsers();
|
renderAdminUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,14 +647,15 @@ const addCommand = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!COMMAND_NAME_REGEX.test(value)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.commands.includes(value)) {
|
if (!state.commands.includes(value)) {
|
||||||
state.commands.push(value);
|
state.commands.push(value);
|
||||||
|
setDirty(true);
|
||||||
}
|
}
|
||||||
elements.newCommandInput.value = '';
|
elements.newCommandInput.value = '';
|
||||||
renderCommandsTable();
|
renderCommands();
|
||||||
};
|
};
|
||||||
|
|
||||||
const savePermissions = async () => {
|
const savePermissions = async () => {
|
||||||
@@ -342,38 +663,38 @@ const savePermissions = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminRoleIds = Array.from(elements.adminRolesSelect.selectedOptions).map((option) => option.value);
|
setLoadingDetails(true);
|
||||||
state.permissions.adminRoleIds = adminRoleIds;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
adminRoleIds: state.permissions.adminRoleIds,
|
|
||||||
adminUserIds: state.permissions.adminUserIds,
|
|
||||||
commandPermissions: state.permissions.commandPermissions,
|
|
||||||
};
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
|
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(state.permissions),
|
||||||
});
|
});
|
||||||
state.permissions = data.permissions;
|
state.permissions = normalizePermissions(data.permissions || {});
|
||||||
elements.permissionsStatus.textContent = 'Permissions saved successfully.';
|
state.originalPermissions = clonePermissions(state.permissions);
|
||||||
dashboardApi.setBanner(elements.banner, 'Permissions updated successfully.', 'success');
|
setDirty(false);
|
||||||
renderRolesSelect();
|
dashboardApi.showToast('Permissions saved.', 'success');
|
||||||
renderAdminUsers();
|
renderPanels();
|
||||||
renderCommandsTable();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboardApi.setBanner(elements.banner, error.message || 'Failed to save permissions.');
|
handleFetchError(error, 'Failed to save permissions.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoadingDetails(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discardChanges = () => {
|
||||||
|
if (!state.originalPermissions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.permissions = clonePermissions(state.originalPermissions);
|
||||||
|
setDirty(false);
|
||||||
|
renderPanels();
|
||||||
|
};
|
||||||
|
|
||||||
const switchTab = (tabName) => {
|
const switchTab = (tabName) => {
|
||||||
elements.tabs.forEach((tab) => {
|
elements.tabs.forEach((tab) => {
|
||||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||||
});
|
});
|
||||||
|
elements.settingsPanel.hidden = tabName !== 'settings';
|
||||||
elements.permissionsPanel.hidden = tabName !== 'permissions';
|
elements.permissionsPanel.hidden = tabName !== 'permissions';
|
||||||
elements.warningsPanel.hidden = tabName !== 'warnings';
|
elements.warningsPanel.hidden = tabName !== 'warnings';
|
||||||
};
|
};
|
||||||
@@ -381,10 +702,10 @@ const switchTab = (tabName) => {
|
|||||||
const fetchWarnings = async () => {
|
const fetchWarnings = async () => {
|
||||||
const userId = elements.warningsUserInput.value.trim();
|
const userId = elements.warningsUserInput.value.trim();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
dashboardApi.setBanner(elements.banner, 'Enter a user ID to fetch warnings.');
|
setError({message: 'Enter a user ID to fetch warnings.'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
elements.warningsSpinner.hidden = false;
|
||||||
try {
|
try {
|
||||||
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
|
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
|
||||||
const warnings = data.warnings || [];
|
const warnings = data.warnings || [];
|
||||||
@@ -395,19 +716,50 @@ const fetchWarnings = async () => {
|
|||||||
elements.warningsEmpty.hidden = true;
|
elements.warningsEmpty.hidden = true;
|
||||||
warnings.forEach((warning) => {
|
warnings.forEach((warning) => {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
|
item.className = 'warning-item';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.textContent = warning.reason;
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'warning-item__meta';
|
||||||
const dateText = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
|
const 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);
|
elements.warningsList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dashboardApi.clearBanner(elements.banner);
|
setError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboardApi.setBanner(elements.banner, error.message || 'Failed to fetch warnings.');
|
handleFetchError(error, 'Failed to fetch warnings.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
elements.warningsSpinner.hidden = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateWarningCount = () => {
|
||||||
|
const length = elements.warningReasonInput.value.length;
|
||||||
|
elements.warningCharCount.textContent = `${length}/200`;
|
||||||
|
};
|
||||||
|
|
||||||
const addWarning = async () => {
|
const addWarning = async () => {
|
||||||
const userId = elements.newWarningUserInput.value.trim();
|
const userId = elements.newWarningUserInput.value.trim();
|
||||||
const reason = elements.warningReasonInput.value.trim();
|
const reason = elements.warningReasonInput.value.trim();
|
||||||
@@ -415,8 +767,12 @@ const addWarning = async () => {
|
|||||||
elements.warningStatus.textContent = 'User ID and reason are required.';
|
elements.warningStatus.textContent = 'User ID and reason are required.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (reason.length > 200) {
|
||||||
|
elements.warningStatus.textContent = 'Reason must be under 200 characters.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
elements.warningStatus.textContent = '';
|
||||||
try {
|
try {
|
||||||
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
|
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -424,23 +780,79 @@ const addWarning = async () => {
|
|||||||
});
|
});
|
||||||
elements.warningStatus.textContent = `Warning added. Total warnings: ${data.warnings?.length || 0}`;
|
elements.warningStatus.textContent = `Warning added. Total warnings: ${data.warnings?.length || 0}`;
|
||||||
elements.warningReasonInput.value = '';
|
elements.warningReasonInput.value = '';
|
||||||
dashboardApi.clearBanner(elements.banner);
|
updateWarningCount();
|
||||||
|
dashboardApi.showToast('Warning saved.', 'success');
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
closeCopyModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initDashboard = async () => {
|
const initDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
await loadSession();
|
await loadSession();
|
||||||
await loadGuilds();
|
await Promise.all([loadGuilds(), loadBotGuilds()]);
|
||||||
|
renderGuilds();
|
||||||
if (state.selectedGuildId) {
|
if (state.selectedGuildId) {
|
||||||
await loadGuildDetails();
|
await loadGuildDetails();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 = '/';
|
window.location.href = '/';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -455,10 +867,50 @@ const initDashboard = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
elements.addAdminUserButton.addEventListener('click', addAdminUser);
|
elements.addAdminUserButton.addEventListener('click', addAdminUser);
|
||||||
|
elements.adminUserInput.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
addAdminUser();
|
||||||
|
}
|
||||||
|
});
|
||||||
elements.addCommandButton.addEventListener('click', addCommand);
|
elements.addCommandButton.addEventListener('click', addCommand);
|
||||||
elements.savePermissionsButton.addEventListener('click', savePermissions);
|
elements.savePermissionsButton.addEventListener('click', savePermissions);
|
||||||
|
elements.discardChangesButton.addEventListener('click', discardChanges);
|
||||||
elements.fetchWarningsButton.addEventListener('click', fetchWarnings);
|
elements.fetchWarningsButton.addEventListener('click', fetchWarnings);
|
||||||
elements.addWarningButton.addEventListener('click', addWarning);
|
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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
+484
-77
@@ -10,6 +10,7 @@
|
|||||||
--primary-dark: #4752c4;
|
--primary-dark: #4752c4;
|
||||||
--success: #3ba55d;
|
--success: #3ba55d;
|
||||||
--danger: #ed4245;
|
--danger: #ed4245;
|
||||||
|
--warning: #f6c343;
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,58 +34,95 @@ button {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
input,
|
||||||
max-width: 1200px;
|
select,
|
||||||
margin: 0 auto;
|
textarea {
|
||||||
padding: 32px 24px 60px;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.app-shell {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 24px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--border);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 12px;
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.toast {
|
||||||
display: inline-flex;
|
opacity: 0;
|
||||||
align-items: center;
|
transform: translateY(-8px);
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(88, 101, 242, 0.2);
|
|
||||||
color: #dfe1ff;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #22283a;
|
transition: all 0.2s ease;
|
||||||
margin-bottom: 16px;
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner--error {
|
.toast--visible {
|
||||||
border-color: rgba(237, 66, 69, 0.4);
|
opacity: 1;
|
||||||
background: rgba(237, 66, 69, 0.15);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner--success {
|
.toast--success {
|
||||||
border-color: rgba(59, 165, 93, 0.4);
|
border-color: rgba(59, 165, 93, 0.5);
|
||||||
background: rgba(59, 165, 93, 0.15);
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
border-color: rgba(237, 66, 69, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
@@ -93,22 +131,38 @@ button {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.sidebar {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guild-list {
|
.sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
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 {
|
.guild-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -117,6 +171,13 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guild-item__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.guild-item img {
|
.guild-item img {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -124,20 +185,103 @@ button {
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guild-item span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.guild-item.active {
|
.guild-item.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.status-pill {
|
||||||
margin-bottom: 24px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 16px;
|
||||||
gap: 12px;
|
}
|
||||||
margin-bottom: 12px;
|
|
||||||
|
.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 {
|
.tabs {
|
||||||
@@ -147,7 +291,7 @@ button {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
margin-bottom: 16px;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@@ -164,11 +308,43 @@ button {
|
|||||||
color: white;
|
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 {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
@@ -182,12 +358,76 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field textarea {
|
.field textarea {
|
||||||
min-height: 90px;
|
min-height: 100px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multi-select {
|
.field__meta {
|
||||||
min-height: 120px;
|
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 {
|
.button {
|
||||||
@@ -219,71 +459,238 @@ button {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-list {
|
.command-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 16px;
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip button {
|
.toggle input {
|
||||||
border: none;
|
width: 40px;
|
||||||
background: transparent;
|
height: 20px;
|
||||||
color: var(--muted);
|
appearance: none;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.toggle input::after {
|
||||||
width: 100%;
|
content: '';
|
||||||
border-collapse: collapse;
|
position: absolute;
|
||||||
margin-top: 8px;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: white;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th,
|
.toggle input:checked {
|
||||||
.table td {
|
background: var(--primary);
|
||||||
text-align: left;
|
}
|
||||||
|
|
||||||
|
.toggle input:checked::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-bottom: 1px solid var(--border);
|
margin-bottom: 10px;
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th {
|
.warning-item__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.warnings-layout {
|
||||||
color: var(--muted);
|
display: grid;
|
||||||
font-size: 0.9rem;
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.warnings-pane {
|
||||||
opacity: 0.7;
|
background: var(--card);
|
||||||
pointer-events: none;
|
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 {
|
.empty-state {
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
background: #151a27;
|
background: #151a27;
|
||||||
color: var(--muted);
|
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 {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warnings-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user