Merge pull request #12 from Fretuks/main
Implement alot of changes, adding backend and frontend for the bot dashboard.
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
"warn",
|
||||||
|
"warnings",
|
||||||
|
"ban",
|
||||||
|
"kick",
|
||||||
|
"mute",
|
||||||
|
"unmute"
|
||||||
|
]
|
||||||
+317
-24
@@ -3,7 +3,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const {addWarning, getWarnings} = require('./services/warnings');
|
const {addWarning, getWarnings, clearWarnings} = require('./services/warnings');
|
||||||
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
|
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
|
||||||
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const PERMISSION_BITS = {
|
const PERMISSION_BITS = {
|
||||||
@@ -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 ||
|
||||||
@@ -56,8 +60,17 @@ console.log('[oauth config]', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DISCORD_API_BASE = 'https://discord.com/api';
|
const DISCORD_API_BASE = 'https://discord.com/api';
|
||||||
|
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
|
||||||
|
const COMMANDS_PATH = path.join(__dirname, 'commands.json');
|
||||||
|
const GUILD_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const BOT_GUILD_CACHE_TTL_MS = 60 * 1000;
|
||||||
|
const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.';
|
||||||
|
|
||||||
const sessionStore = new Map();
|
const sessionStore = new Map();
|
||||||
|
const botGuildCache = {
|
||||||
|
guildIds: null,
|
||||||
|
fetchedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const parseCookies = (headerValue) => {
|
const parseCookies = (headerValue) => {
|
||||||
const cookies = {};
|
const cookies = {};
|
||||||
@@ -116,12 +129,10 @@ const getSession = (sessionId) => {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.expiresAt <= Date.now()) {
|
if (session.expiresAt <= Date.now()) {
|
||||||
sessionStore.delete(sessionId);
|
sessionStore.delete(sessionId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,7 +144,6 @@ const ensureOAuthConfig = () => {
|
|||||||
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) {
|
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientId: DISCORD_CLIENT_ID,
|
clientId: DISCORD_CLIENT_ID,
|
||||||
clientSecret: DISCORD_CLIENT_SECRET,
|
clientSecret: DISCORD_CLIENT_SECRET,
|
||||||
@@ -141,6 +151,22 @@ const ensureOAuthConfig = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDiscordStatusCode = (error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
return typeof status === 'number' ? status : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendDiscordApiError = (res, error, fallbackMessage) => {
|
||||||
|
const status = getDiscordStatusCode(error);
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'});
|
||||||
|
}
|
||||||
|
if (status === 429) {
|
||||||
|
return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'});
|
||||||
|
}
|
||||||
|
return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage});
|
||||||
|
};
|
||||||
|
|
||||||
const buildAuthorizationUrl = (state) => {
|
const buildAuthorizationUrl = (state) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: DISCORD_CLIENT_ID,
|
client_id: DISCORD_CLIENT_ID,
|
||||||
@@ -150,8 +176,7 @@ const buildAuthorizationUrl = (state) => {
|
|||||||
state,
|
state,
|
||||||
prompt: 'consent',
|
prompt: 'consent',
|
||||||
});
|
});
|
||||||
|
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
|
||||||
return `${DISCORD_API_BASE}/oauth2/authorize?${params.toString()}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDiscordAccessToken = async (code) => {
|
const fetchDiscordAccessToken = async (code) => {
|
||||||
@@ -163,7 +188,7 @@ const fetchDiscordAccessToken = async (code) => {
|
|||||||
redirect_uri: DISCORD_REDIRECT_URI,
|
redirect_uri: DISCORD_REDIRECT_URI,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params, {
|
const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params.toString(), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
@@ -194,7 +219,7 @@ const fetchDiscordUserGuilds = async (accessToken) => {
|
|||||||
|
|
||||||
const fetchDiscordGuildRoles = async (guildId) => {
|
const fetchDiscordGuildRoles = async (guildId) => {
|
||||||
if (!DISCORD_BOT_TOKEN) {
|
if (!DISCORD_BOT_TOKEN) {
|
||||||
throw new Error('Missing Discord bot token.');
|
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
|
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
|
||||||
@@ -206,6 +231,94 @@ const fetchDiscordGuildRoles = async (guildId) => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchDiscordGuildMembersSearch = async (guildId, query) => {
|
||||||
|
if (!DISCORD_BOT_TOKEN) {
|
||||||
|
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/members/search`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDiscordBotGuilds = async () => {
|
||||||
|
if (!DISCORD_BOT_TOKEN) {
|
||||||
|
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCachedBotGuildIds = () => {
|
||||||
|
if (!Array.isArray(botGuildCache.guildIds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!botGuildCache.fetchedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() - botGuildCache.fetchedAt > BOT_GUILD_CACHE_TTL_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return botGuildCache.guildIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCachedBotGuildIds = (guildIds) => {
|
||||||
|
botGuildCache.guildIds = guildIds;
|
||||||
|
botGuildCache.fetchedAt = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInviteUrl = (guildId) => {
|
||||||
|
if (!DISCORD_CLIENT_ID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const permissions = Number.isNaN(DASHBOARD_INVITE_PERMISSIONS) ? 0 : DASHBOARD_INVITE_PERMISSIONS;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: DISCORD_CLIENT_ID,
|
||||||
|
scope: 'bot applications.commands',
|
||||||
|
permissions: String(permissions),
|
||||||
|
disable_guild_select: 'true',
|
||||||
|
});
|
||||||
|
if (guildId) {
|
||||||
|
params.set('guild_id', guildId);
|
||||||
|
}
|
||||||
|
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheSessionGuilds = (session, guilds) => {
|
||||||
|
session.guilds = guilds;
|
||||||
|
session.guildsFetchedAt = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCachedGuilds = (session) => {
|
||||||
|
if (!Array.isArray(session.guilds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.guildsFetchedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - session.guildsFetchedAt > GUILD_CACHE_TTL_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.guilds;
|
||||||
|
};
|
||||||
|
|
||||||
const parsePermissionBits = (permissionValue) => {
|
const parsePermissionBits = (permissionValue) => {
|
||||||
if (!permissionValue) {
|
if (!permissionValue) {
|
||||||
return 0n;
|
return 0n;
|
||||||
@@ -218,6 +331,34 @@ const parsePermissionBits = (permissionValue) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isHttpsRequest = (req) => {
|
||||||
|
if (req.secure) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||||
|
if (typeof forwardedProto === 'string' && forwardedProto.split(',')[0].trim() === 'https') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(config.cookieSecure);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCommands = () => {
|
||||||
|
if (!fs.existsSync(COMMANDS_PATH)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load commands.json', {message: error.message});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const canManageGuild = (guild) => {
|
const canManageGuild = (guild) => {
|
||||||
const permissions = parsePermissionBits(guild.permissions);
|
const permissions = parsePermissionBits(guild.permissions);
|
||||||
return (
|
return (
|
||||||
@@ -245,7 +386,11 @@ const ensureSession = (req, res, next) => {
|
|||||||
|
|
||||||
const ensureManageableGuild = async (req, res, next) => {
|
const ensureManageableGuild = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
|
let guilds = getCachedGuilds(req.session);
|
||||||
|
if (!guilds) {
|
||||||
|
guilds = await fetchDiscordUserGuilds(req.session.accessToken);
|
||||||
|
cacheSessionGuilds(req.session, guilds);
|
||||||
|
}
|
||||||
const guild = guilds.find((entry) => entry.id === req.params.guildId);
|
const guild = guilds.find((entry) => entry.id === req.params.guildId);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
return res.status(404).json({error: 'Guild not found'});
|
return res.status(404).json({error: 'Guild not found'});
|
||||||
@@ -257,7 +402,7 @@ const ensureManageableGuild = async (req, res, next) => {
|
|||||||
req.guild = guild;
|
req.guild = guild;
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(502).json({error: 'Failed to fetch guilds'});
|
return sendDiscordApiError(res, error, 'Failed to fetch guilds');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,7 +421,7 @@ const createDashboardApp = () => {
|
|||||||
setCookie(res, 'oauth_state', state, {
|
setCookie(res, 'oauth_state', state, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
secure: req.secure,
|
secure: isHttpsRequest(req),
|
||||||
maxAge: 300,
|
maxAge: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,7 +443,7 @@ const createDashboardApp = () => {
|
|||||||
const tokenData = await fetchDiscordAccessToken(code);
|
const tokenData = await fetchDiscordAccessToken(code);
|
||||||
const user = await fetchDiscordUser(tokenData.access_token);
|
const user = await fetchDiscordUser(tokenData.access_token);
|
||||||
|
|
||||||
const {sessionId, expiresAt} = createSession({
|
const session = createSession({
|
||||||
accessToken: tokenData.access_token,
|
accessToken: tokenData.access_token,
|
||||||
refreshToken: tokenData.refresh_token,
|
refreshToken: tokenData.refresh_token,
|
||||||
tokenType: tokenData.token_type,
|
tokenType: tokenData.token_type,
|
||||||
@@ -306,11 +451,11 @@ const createDashboardApp = () => {
|
|||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(res, 'dashboard_session', sessionId, {
|
setCookie(res, 'dashboard_session', session.sessionId, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
secure: req.secure,
|
secure: isHttpsRequest(req),
|
||||||
maxAge: Math.floor((expiresAt - Date.now()) / 1000),
|
maxAge: Math.floor((session.expiresAt - Date.now()) / 1000),
|
||||||
});
|
});
|
||||||
clearCookie(res, 'oauth_state');
|
clearCookie(res, 'oauth_state');
|
||||||
|
|
||||||
@@ -319,7 +464,7 @@ const createDashboardApp = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.accepts('html')) {
|
if (req.accepts('html')) {
|
||||||
return res.redirect('/');
|
return res.redirect('/dashboard.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({user});
|
return res.status(200).json({user});
|
||||||
@@ -327,12 +472,12 @@ const createDashboardApp = () => {
|
|||||||
console.error('Discord OAuth failed', {
|
console.error('Discord OAuth failed', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
data: error.response?.data,
|
data: error.response?.data?.error || error.response?.data?.error_description,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(502).json({
|
return res.status(502).json({
|
||||||
error: 'Failed to authenticate with Discord',
|
error: 'Failed to authenticate with Discord',
|
||||||
details: error.response?.data || error.message,
|
details: error.response?.data?.error_description || error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -343,16 +488,50 @@ const createDashboardApp = () => {
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/me', ensureSession, (req, res) => {
|
||||||
|
return res.json({
|
||||||
|
user: req.session.user,
|
||||||
|
expiresAt: req.session.expiresAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/guilds', ensureSession, async (req, res) => {
|
app.get('/api/guilds', ensureSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
|
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
|
||||||
|
cacheSessionGuilds(req.session, guilds);
|
||||||
const manageableGuilds = guilds.filter(canManageGuild);
|
const manageableGuilds = guilds.filter(canManageGuild);
|
||||||
return res.json({guilds: manageableGuilds});
|
return res.json({guilds: manageableGuilds});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(502).json({error: 'Failed to fetch guilds'});
|
return sendDiscordApiError(res, error, 'Failed to fetch guilds');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -362,7 +541,11 @@ const createDashboardApp = () => {
|
|||||||
const permissions = await getGuildPermissions(req.params.guildId);
|
const permissions = await getGuildPermissions(req.params.guildId);
|
||||||
return res.json({permissions});
|
return res.json({permissions});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({error: 'Failed to fetch permissions'});
|
console.error('Failed to fetch guild permissions', {
|
||||||
|
guildId: req.params.guildId,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
return res.status(503).json({error: 'Permissions store unavailable'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -379,7 +562,11 @@ const createDashboardApp = () => {
|
|||||||
});
|
});
|
||||||
return res.json({permissions});
|
return res.json({permissions});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({error: 'Failed to update permissions'});
|
console.error('Failed to update guild permissions', {
|
||||||
|
guildId: req.params.guildId,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
return res.status(503).json({error: 'Permissions store unavailable'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -393,11 +580,80 @@ 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) {
|
||||||
return res.status(500).json({error: 'Failed to fetch roles'});
|
const status = getDiscordStatusCode(error);
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'BOT_NOT_IN_GUILD',
|
||||||
|
message: 'Bot not added to this guild.',
|
||||||
|
inviteUrl: buildInviteUrl(req.params.guildId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
|
||||||
|
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
|
||||||
|
}
|
||||||
|
return sendDiscordApiError(res, error, 'Failed to fetch roles');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/guilds/:guildId/members',
|
||||||
|
ensureSession,
|
||||||
|
ensureManageableGuild,
|
||||||
|
async (req, res) => {
|
||||||
|
const query = typeof req.query.query === 'string' ? req.query.query.trim() : '';
|
||||||
|
if (query.length < 2) {
|
||||||
|
return res.json({members: []});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
|
||||||
|
const normalized = (members || [])
|
||||||
|
.map((member) => {
|
||||||
|
const user = member.user || {};
|
||||||
|
if (!user.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username || '',
|
||||||
|
discriminator: user.discriminator || '',
|
||||||
|
global_name: user.global_name || '',
|
||||||
|
avatar: user.avatar || '',
|
||||||
|
},
|
||||||
|
nick: member.nick || '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return res.json({members: normalized});
|
||||||
|
} catch (error) {
|
||||||
|
const status = getDiscordStatusCode(error);
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'BOT_NOT_IN_GUILD',
|
||||||
|
message: 'Bot not added to this guild.',
|
||||||
|
inviteUrl: buildInviteUrl(req.params.guildId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
|
||||||
|
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
|
||||||
|
}
|
||||||
|
return sendDiscordApiError(res, error, 'Failed to search guild members');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/guilds/:guildId/commands',
|
||||||
|
ensureSession,
|
||||||
|
ensureManageableGuild,
|
||||||
|
(req, res) => {
|
||||||
|
const commands = loadCommands();
|
||||||
|
return res.json({commands});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/api/guilds/:guildId/warnings/:userId',
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
@@ -408,7 +664,11 @@ const createDashboardApp = () => {
|
|||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
});
|
});
|
||||||
return res.json({warnings});
|
const normalizedWarnings = warnings.map((warning) => ({
|
||||||
|
...warning,
|
||||||
|
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
|
||||||
|
}));
|
||||||
|
return res.json({warnings: normalizedWarnings});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({error: 'Failed to fetch warnings'});
|
return res.status(500).json({error: 'Failed to fetch warnings'});
|
||||||
}
|
}
|
||||||
@@ -431,12 +691,45 @@ const createDashboardApp = () => {
|
|||||||
moderatorId: req.session.user?.id,
|
moderatorId: req.session.user?.id,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
return res.status(201).json(result);
|
const warnings = (result.warnings || []).map((warning) => ({
|
||||||
|
...warning,
|
||||||
|
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
|
||||||
|
}));
|
||||||
|
const warningEntry = result.warningEntry
|
||||||
|
? {
|
||||||
|
...result.warningEntry,
|
||||||
|
createdAt: result.warningEntry.createdAt
|
||||||
|
? new Date(result.warningEntry.createdAt).toISOString()
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return res.status(201).json({
|
||||||
|
...result,
|
||||||
|
warnings,
|
||||||
|
warningEntry,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({error: 'Failed to add warning'});
|
return res.status(500).json({error: 'Failed to add warning'});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
|
ensureSession,
|
||||||
|
ensureManageableGuild,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const warnings = await clearWarnings({
|
||||||
|
guildId: req.params.guildId,
|
||||||
|
userId: req.params.userId,
|
||||||
|
});
|
||||||
|
return res.json({warnings});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({error: 'Failed to clear warnings'});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
return app;
|
return app;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const path = require('node:path');
|
|||||||
const { REST, Routes } = require('discord.js');
|
const { REST, Routes } = require('discord.js');
|
||||||
const { clientId, token } = require('./config.json');
|
const { clientId, token } = require('./config.json');
|
||||||
const { loadCommandModules } = require('./loaders/commands');
|
const { loadCommandModules } = require('./loaders/commands');
|
||||||
|
|
||||||
const commandsPath = path.join(__dirname, 'commands');
|
const commandsPath = path.join(__dirname, 'commands');
|
||||||
const { commands, invalidCommands } = loadCommandModules(commandsPath);
|
const { commands, invalidCommands } = loadCommandModules(commandsPath);
|
||||||
const commandsData = commands.map((command) => command.data.toJSON());
|
const commandsData = commands.map((command) => command.data.toJSON());
|
||||||
@@ -11,23 +10,17 @@ for (const filePath of invalidCommands) {
|
|||||||
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct and prepare an instance of the REST module
|
|
||||||
const rest = new REST().setToken(token);
|
const rest = new REST().setToken(token);
|
||||||
|
|
||||||
// and deploy your commands!
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||||
|
|
||||||
// The put method is used to fully refresh all commands in the guild with the current set
|
|
||||||
const data = await rest.put(
|
const data = await rest.put(
|
||||||
Routes.applicationCommands(clientId),
|
Routes.applicationCommands(clientId),
|
||||||
{ body: commandsData },
|
{ body: commandsData },
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
|
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// And of course, make sure you catch and log any errors!
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Generated
+204
-1
@@ -11,8 +11,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"connect-mongo": "^5.1.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"discord.js": "^14.17.3",
|
"discord.js": "^14.17.3",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mongodb": "^6.12.0",
|
"mongodb": "^6.12.0",
|
||||||
"openai": "^4.67.3",
|
"openai": "^4.67.3",
|
||||||
@@ -393,6 +398,18 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -468,6 +485,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||||
|
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -669,6 +692,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/connect-mongo": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.1",
|
||||||
|
"kruptein": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express-session": "^1.17.1",
|
||||||
|
"mongodb": ">= 5.1.0 < 7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/console-control-strings": {
|
"node_modules/console-control-strings": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||||
@@ -706,18 +746,56 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
@@ -1001,6 +1079,74 @@
|
|||||||
"node": ">= 0.10.0"
|
"node": ">= 0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cookie-signature": "~1.0.7",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "~5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/express/node_modules/debug": {
|
"node_modules/express/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -1507,6 +1653,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/kruptein": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-Wk3WFZdGtjnTBGlC12SiBsEh51+6lsW7sSRm+MzRVRc3WRPrUQGu4+/n3YQjSPp8x1AJJxPxgLsekFXLiBN2PQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
@@ -1644,6 +1802,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -2014,6 +2178,15 @@
|
|||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
|
||||||
@@ -2038,6 +2211,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -2229,6 +2411,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -2783,6 +2974,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.19.8",
|
"version": "6.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
|
||||||
|
|||||||
+117
-181
@@ -1,223 +1,159 @@
|
|||||||
const state = {
|
const createFetchError = async (response) => {
|
||||||
guilds: [],
|
let payload = {};
|
||||||
selectedGuildId: null,
|
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 elements = {
|
const fetchJson = async (url, options = {}) => {
|
||||||
loginButton: document.getElementById('loginButton'),
|
|
||||||
logoutButton: document.getElementById('logoutButton'),
|
|
||||||
sessionStatus: document.getElementById('sessionStatus'),
|
|
||||||
guildCard: document.getElementById('guildCard'),
|
|
||||||
warningsCard: document.getElementById('warningsCard'),
|
|
||||||
guildSelect: document.getElementById('guildSelect'),
|
|
||||||
permissionsEditor: document.getElementById('permissionsEditor'),
|
|
||||||
updatePermissionsButton: document.getElementById('updatePermissionsButton'),
|
|
||||||
rolesList: document.getElementById('rolesList'),
|
|
||||||
warningUserId: document.getElementById('warningUserId'),
|
|
||||||
loadWarningsButton: document.getElementById('loadWarningsButton'),
|
|
||||||
warningsList: document.getElementById('warningsList'),
|
|
||||||
newWarningUserId: document.getElementById('newWarningUserId'),
|
|
||||||
warningReason: document.getElementById('warningReason'),
|
|
||||||
addWarningButton: document.getElementById('addWarningButton'),
|
|
||||||
warningResult: document.getElementById('warningResult'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const setStatus = (message, isAuthed) => {
|
|
||||||
elements.sessionStatus.textContent = message;
|
|
||||||
elements.logoutButton.hidden = !isAuthed;
|
|
||||||
elements.guildCard.hidden = !isAuthed;
|
|
||||||
elements.warningsCard.hidden = !isAuthed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJson = async (url, options) => {
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
...options,
|
...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}`;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json().catch(() => ({}));
|
return response.json().catch(() => ({}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadGuilds = async () => {
|
const showToast = (message, type = 'success') => {
|
||||||
const data = await fetchJson('/api/guilds');
|
const container = document.getElementById('toastContainer');
|
||||||
state.guilds = data.guilds || [];
|
if (!container) {
|
||||||
elements.guildSelect.innerHTML = '';
|
|
||||||
|
|
||||||
state.guilds.forEach((guild) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = guild.id;
|
|
||||||
option.textContent = guild.name;
|
|
||||||
elements.guildSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.guilds.length > 0) {
|
|
||||||
state.selectedGuildId = state.guilds[0].id;
|
|
||||||
elements.guildSelect.value = state.selectedGuildId;
|
|
||||||
await loadGuildDetails();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRoles = (roles) => {
|
|
||||||
elements.rolesList.innerHTML = '';
|
|
||||||
roles.forEach((role) => {
|
|
||||||
const item = document.createElement('li');
|
|
||||||
item.textContent = `${role.name} (${role.id})`;
|
|
||||||
elements.rolesList.appendChild(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (roles.length === 0) {
|
|
||||||
const item = document.createElement('li');
|
|
||||||
item.textContent = 'No roles returned.';
|
|
||||||
elements.rolesList.appendChild(item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadGuildDetails = async () => {
|
|
||||||
if (!state.selectedGuildId) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const toast = document.createElement('div');
|
||||||
const [permissionsData, rolesData] = await Promise.all([
|
toast.className = `toast toast--${type}`;
|
||||||
fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`),
|
toast.textContent = message;
|
||||||
fetchJson(`/api/guilds/${state.selectedGuildId}/roles`),
|
container.appendChild(toast);
|
||||||
]);
|
setTimeout(() => {
|
||||||
|
toast.classList.add('toast--visible');
|
||||||
elements.permissionsEditor.value = JSON.stringify(permissionsData.permissions || {}, null, 2);
|
}, 10);
|
||||||
renderRoles(rolesData.roles || []);
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('toast--visible');
|
||||||
|
setTimeout(() => toast.remove(), 200);
|
||||||
|
}, 3200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePermissions = async () => {
|
const setErrorBanner = (bannerEl, message, details) => {
|
||||||
if (!state.selectedGuildId) {
|
if (!bannerEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const messageEl = bannerEl.querySelector('[data-role="error-message"]');
|
||||||
const rawValue = elements.permissionsEditor.value.trim();
|
const copyButton = bannerEl.querySelector('[data-role="error-copy"]');
|
||||||
if (!rawValue) {
|
if (messageEl) {
|
||||||
return;
|
messageEl.textContent = message;
|
||||||
}
|
}
|
||||||
|
if (copyButton) {
|
||||||
let payload;
|
copyButton.onclick = async () => {
|
||||||
try {
|
const payload = details || {message};
|
||||||
payload = JSON.parse(rawValue);
|
try {
|
||||||
} catch (error) {
|
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||||
elements.permissionsEditor.focus();
|
showToast('Error details copied.', 'success');
|
||||||
throw new Error('Permissions JSON is invalid.');
|
} catch (error) {
|
||||||
|
showToast('Unable to copy details.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
bannerEl.hidden = false;
|
||||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.permissionsEditor.value = JSON.stringify(data.permissions || {}, null, 2);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadWarnings = async (userId) => {
|
const clearErrorBanner = (bannerEl) => {
|
||||||
if (!state.selectedGuildId || !userId) {
|
if (!bannerEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
bannerEl.hidden = true;
|
||||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
|
|
||||||
elements.warningsList.innerHTML = '';
|
|
||||||
|
|
||||||
const warnings = data.warnings || [];
|
|
||||||
if (warnings.length === 0) {
|
|
||||||
const item = document.createElement('li');
|
|
||||||
item.textContent = 'No warnings found.';
|
|
||||||
elements.warningsList.appendChild(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
warnings.forEach((warning) => {
|
|
||||||
const item = document.createElement('li');
|
|
||||||
const issuedAt = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
|
|
||||||
item.textContent = `${warning.reason} — ${issuedAt}`;
|
|
||||||
elements.warningsList.appendChild(item);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addWarning = async () => {
|
const formatUserDisplay = (user) => {
|
||||||
if (!state.selectedGuildId) {
|
if (!user) {
|
||||||
return;
|
return 'Not signed in';
|
||||||
}
|
}
|
||||||
|
const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : '';
|
||||||
const userId = elements.newWarningUserId.value.trim();
|
return `${user.username}${tag}`;
|
||||||
const reason = elements.warningReason.value.trim();
|
|
||||||
|
|
||||||
if (!userId || !reason) {
|
|
||||||
elements.warningResult.textContent = 'User ID and reason are required.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ reason }),
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.warningResult.textContent = `Warning added: ${data.warning?.reason || reason}`;
|
|
||||||
elements.warningReason.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showError = (message) => {
|
const debounce = (callback, wait = 300) => {
|
||||||
elements.warningResult.textContent = message;
|
let timeoutId;
|
||||||
};
|
return (...args) => {
|
||||||
|
if (timeoutId) {
|
||||||
const init = async () => {
|
clearTimeout(timeoutId);
|
||||||
elements.loginButton.addEventListener('click', () => {
|
|
||||||
window.location.href = '/auth/discord';
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.logoutButton.addEventListener('click', async () => {
|
|
||||||
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
|
|
||||||
setStatus('Logged out. Sign in to manage guilds.', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.guildSelect.addEventListener('change', async (event) => {
|
|
||||||
state.selectedGuildId = event.target.value;
|
|
||||||
await loadGuildDetails();
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.updatePermissionsButton.addEventListener('click', async () => {
|
|
||||||
elements.warningResult.textContent = '';
|
|
||||||
try {
|
|
||||||
await updatePermissions();
|
|
||||||
elements.warningResult.textContent = 'Permissions updated successfully.';
|
|
||||||
} catch (error) {
|
|
||||||
showError(error.message);
|
|
||||||
}
|
}
|
||||||
});
|
timeoutId = setTimeout(() => callback(...args), wait);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
elements.loadWarningsButton.addEventListener('click', async () => {
|
const initIndexPage = async () => {
|
||||||
try {
|
const sessionStatus = document.getElementById('sessionStatus');
|
||||||
await loadWarnings(elements.warningUserId.value.trim());
|
const loginButton = document.getElementById('loginButton');
|
||||||
} catch (error) {
|
const dashboardButton = document.getElementById('dashboardButton');
|
||||||
showError(error.message);
|
const logoutButton = document.getElementById('logoutButton');
|
||||||
}
|
const banner = document.getElementById('banner');
|
||||||
});
|
|
||||||
|
|
||||||
elements.addWarningButton.addEventListener('click', async () => {
|
if (!sessionStatus || !loginButton) {
|
||||||
try {
|
return;
|
||||||
await addWarning();
|
}
|
||||||
} catch (error) {
|
|
||||||
showError(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadGuilds();
|
const data = await fetchJson('/api/me');
|
||||||
setStatus('Authenticated. Choose a guild to get started.', true);
|
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
||||||
|
loginButton.hidden = true;
|
||||||
|
dashboardButton.hidden = false;
|
||||||
|
logoutButton.hidden = false;
|
||||||
|
if (banner) {
|
||||||
|
banner.textContent = '';
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('Sign in with Discord to manage your guilds.', false);
|
sessionStatus.textContent = 'You are not signed in yet.';
|
||||||
|
loginButton.hidden = false;
|
||||||
|
dashboardButton.hidden = true;
|
||||||
|
logoutButton.hidden = true;
|
||||||
|
if (banner) {
|
||||||
|
banner.textContent = 'Log in with Discord to access the dashboard.';
|
||||||
|
banner.style.display = 'block';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logoutButton?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
||||||
|
sessionStatus.textContent = 'Logged out successfully.';
|
||||||
|
loginButton.hidden = false;
|
||||||
|
dashboardButton.hidden = true;
|
||||||
|
logoutButton.hidden = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (banner) {
|
||||||
|
banner.textContent = error.message || 'Failed to log out.';
|
||||||
|
banner.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initIndexPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dashboardApi = {
|
||||||
|
fetchJson,
|
||||||
|
showToast,
|
||||||
|
setErrorBanner,
|
||||||
|
clearErrorBanner,
|
||||||
|
formatUserDisplay,
|
||||||
|
debounce,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Discord Bot Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="navbar">
|
||||||
|
<div class="navbar__brand">
|
||||||
|
<div class="brand-mark">DB</div>
|
||||||
|
<div>
|
||||||
|
<p class="muted">Discord Bot Dashboard</p>
|
||||||
|
<strong id="userDisplay">Loading session...</strong>
|
||||||
|
<p class="muted" id="sessionExpiry"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar__actions">
|
||||||
|
<a class="button button--ghost" href="/">Home</a>
|
||||||
|
<button class="button button--primary" id="logoutButton">Log out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="error-banner" id="errorBanner" hidden>
|
||||||
|
<div>
|
||||||
|
<strong>Something went wrong.</strong>
|
||||||
|
<p data-role="error-message"></p>
|
||||||
|
</div>
|
||||||
|
<button class="button button--ghost" data-role="error-copy">Copy error details</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar__header">
|
||||||
|
<div>
|
||||||
|
<h2>Guilds</h2>
|
||||||
|
<p class="muted">Pick a server to manage.</p>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="guildSearch" placeholder="Search guilds" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guild-list" id="guildList"></div>
|
||||||
|
<div class="skeleton-list" id="guildListSkeleton" hidden>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" id="guildEmpty" hidden>
|
||||||
|
No manageable guilds found. Make sure you have Manage Server permissions.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<section class="guild-header">
|
||||||
|
<div class="guild-header__info">
|
||||||
|
<img id="guildHeaderIcon" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1 id="guildHeaderName">Select a guild</h1>
|
||||||
|
<div class="guild-header__status">
|
||||||
|
<span class="status-badge" id="guildStatusBadge"></span>
|
||||||
|
<span class="muted" id="guildStatusNote"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="guild-header__actions">
|
||||||
|
<button class="button button--primary" id="inviteButton" hidden>Invite bot</button>
|
||||||
|
<button class="button button--ghost" id="refreshBotButton" hidden>Refresh status</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="settings">Settings</button>
|
||||||
|
<button class="tab" data-tab="permissions">Permissions</button>
|
||||||
|
<button class="tab" data-tab="warnings">Warnings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel" id="settingsPanel">
|
||||||
|
<div class="panel__header">
|
||||||
|
<div>
|
||||||
|
<h2>Admin access</h2>
|
||||||
|
<p class="muted">Control who can edit bot configuration and permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="callout" id="botInactiveCallout" hidden>
|
||||||
|
<strong>Bot not added yet.</strong>
|
||||||
|
<p class="muted">The bot isn’t in this server yet. Invite it to configure settings.</p>
|
||||||
|
<p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span>
|
||||||
|
Admin roles
|
||||||
|
<span class="tooltip" title="Members with these roles can edit dashboard settings.">?</span>
|
||||||
|
</span>
|
||||||
|
<div id="adminRolesPicker"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span>
|
||||||
|
Admin user IDs
|
||||||
|
<span class="tooltip" title="Paste one or more user IDs separated by commas or spaces.">?</span>
|
||||||
|
</span>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="adminUserInput" placeholder="Paste user IDs" />
|
||||||
|
<button class="button" id="addAdminUserButton">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="chip-list" id="adminUserChips"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="permissionsPanel" hidden>
|
||||||
|
<div class="panel__header panel__header--split">
|
||||||
|
<div>
|
||||||
|
<h2>Command permissions</h2>
|
||||||
|
<p class="muted">Restrict commands to roles or specific users.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
|
||||||
|
<button class="button" id="copyFromGuild">Copy from another guild</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span>
|
||||||
|
Add command
|
||||||
|
<span class="tooltip" title="Create new command overrides for custom commands.">?</span>
|
||||||
|
</span>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="newCommandInput" placeholder="command-name" />
|
||||||
|
<button class="button" id="addCommandButton">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="command-list" id="commandsList"></div>
|
||||||
|
<div class="empty-state" id="commandsEmpty" hidden>
|
||||||
|
No commands configured yet. Add one or invite the bot to sync commands.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="warningsPanel" hidden>
|
||||||
|
<div class="warnings-layout">
|
||||||
|
<div class="warnings-pane">
|
||||||
|
<h2>User lookup</h2>
|
||||||
|
<p class="muted">Search members or paste an ID.</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="warningsUserSearch">Find member</label>
|
||||||
|
<input type="search" id="warningsUserSearch" placeholder="Type a username" />
|
||||||
|
</div>
|
||||||
|
<div class="member-list" id="warningsUserResults"></div>
|
||||||
|
<p class="muted" id="warningsSelectedUser">No user selected.</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="warningsUserInput">User ID</label>
|
||||||
|
<input type="text" id="warningsUserInput" placeholder="123456789" />
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="button" id="fetchWarningsButton">Fetch warnings</button>
|
||||||
|
<button class="button button--ghost" id="clearWarningsButton">Clear warnings</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="save-bar" id="saveBar" hidden>
|
||||||
|
<span class="save-indicator" id="dirtyIndicator">Unsaved changes</span>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="button button--ghost" id="discardChangesButton">Discard</button>
|
||||||
|
<button class="button button--primary" id="savePermissionsButton">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel skeleton-panel" id="contentSkeleton" hidden>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="copyModal" hidden>
|
||||||
|
<div class="modal__content">
|
||||||
|
<h3>Copy permissions</h3>
|
||||||
|
<p class="muted">Select another guild to copy permissions from.</p>
|
||||||
|
<select id="copyGuildSelect"></select>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="button button--ghost" id="cancelCopyButton">Cancel</button>
|
||||||
|
<button class="button button--primary" id="confirmCopyButton">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script src="dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1069
File diff suppressed because it is too large
Load Diff
+14
-78
@@ -8,90 +8,26 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="app__header">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Discord Bot</p>
|
<div class="badge">Discord Bot Dashboard</div>
|
||||||
<h1>Moderation Dashboard</h1>
|
<h1>Manage your moderation settings</h1>
|
||||||
<p class="subhead">
|
<p class="muted">Configure permissions and warnings without touching JSON.</p>
|
||||||
Manage permissions, review roles, and track warnings without leaving your browser.
|
</div>
|
||||||
</p>
|
<div class="button-row">
|
||||||
|
<a class="button button--primary" href="/auth/discord" id="loginButton">Log in with Discord</a>
|
||||||
|
<a class="button" href="/dashboard.html" id="dashboardButton" hidden>Open dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
<button class="button button--primary" id="loginButton">Log in with Discord</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card" id="sessionCard">
|
<div class="banner" id="banner"></div>
|
||||||
<div>
|
|
||||||
<h2>Session</h2>
|
|
||||||
<p id="sessionStatus">Checking your session...</p>
|
|
||||||
</div>
|
|
||||||
<button class="button button--ghost" id="logoutButton" hidden>Log out</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card" id="guildCard" hidden>
|
<section class="panel">
|
||||||
<div class="card__header">
|
<h2>Session status</h2>
|
||||||
<div>
|
<p class="muted" id="sessionStatus">Checking your session...</p>
|
||||||
<h2>Your guilds</h2>
|
<div class="button-row" style="margin-top: 16px;">
|
||||||
<p>Select a guild to manage permission overrides and warnings.</p>
|
<button class="button button--ghost" id="logoutButton" hidden>Log out</button>
|
||||||
</div>
|
|
||||||
<select id="guildSelect"></select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card__content">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="panel">
|
|
||||||
<h3>Permission overrides</h3>
|
|
||||||
<p class="muted">Update the JSON payload to configure command permissions.</p>
|
|
||||||
<textarea id="permissionsEditor" spellcheck="false"></textarea>
|
|
||||||
<button class="button button--primary" id="updatePermissionsButton">Save permissions</button>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<h3>Roles</h3>
|
|
||||||
<ul id="rolesList" class="list"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card" id="warningsCard" hidden>
|
|
||||||
<div class="card__header">
|
|
||||||
<div>
|
|
||||||
<h2>Warnings</h2>
|
|
||||||
<p>Review or add warnings for a specific user in the selected guild.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card__content">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="panel">
|
|
||||||
<h3>Lookup warnings</h3>
|
|
||||||
<label class="field">
|
|
||||||
<span>User ID</span>
|
|
||||||
<input id="warningUserId" type="text" placeholder="123456789" />
|
|
||||||
</label>
|
|
||||||
<button class="button" id="loadWarningsButton">Load warnings</button>
|
|
||||||
<ul id="warningsList" class="list"></ul>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<h3>Add warning</h3>
|
|
||||||
<label class="field">
|
|
||||||
<span>User ID</span>
|
|
||||||
<input id="newWarningUserId" type="text" placeholder="123456789" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Reason</span>
|
|
||||||
<textarea id="warningReason" placeholder="Reason for warning"></textarea>
|
|
||||||
</label>
|
|
||||||
<button class="button button--primary" id="addWarningButton">Add warning</button>
|
|
||||||
<p class="muted" id="warningResult"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Need help?</h2>
|
|
||||||
<p>
|
|
||||||
The dashboard reads permissions from the MongoDB-backed overrides collection. Use it to grant
|
|
||||||
command access to trusted members in each server.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+785
-89
@@ -1,176 +1,872 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
--bg: #0b1113;
|
||||||
line-height: 1.5;
|
--bg-accent: #122025;
|
||||||
background: #0e111b;
|
--panel: #101820;
|
||||||
color: #eef2ff;
|
--card: #151f27;
|
||||||
|
--border: #24313b;
|
||||||
|
--text: #f4f7fb;
|
||||||
|
--muted: #9fb0bc;
|
||||||
|
--primary: #2cb9c6;
|
||||||
|
--primary-dark: #2197a2;
|
||||||
|
--success: #33d17a;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--warning: #f4b860;
|
||||||
|
--glow: rgba(44, 185, 198, 0.25);
|
||||||
|
font-family: 'Space Grotesk', 'Satoshi', 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: radial-gradient(circle at top, #1d2542 0%, #0e111b 55%);
|
background: radial-gradient(1200px 800px at 15% 10%, rgba(44, 185, 198, 0.12), transparent 60%),
|
||||||
|
radial-gradient(900px 700px at 85% 0%, rgba(244, 184, 96, 0.12), transparent 55%),
|
||||||
|
linear-gradient(180deg, #0b1113 0%, #0e1418 55%, #0b1113 100%);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
max-width: 1100px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px 24px 64px;
|
padding: 48px 24px 96px;
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app__header {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 32px;
|
padding: 28px;
|
||||||
border-radius: 20px;
|
background: linear-gradient(135deg, rgba(44, 185, 198, 0.08), rgba(16, 24, 32, 0.9));
|
||||||
background: rgba(17, 23, 40, 0.9);
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(118, 135, 255, 0.2);
|
border: 1px solid rgba(44, 185, 198, 0.35);
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
margin-bottom: 28px;
|
||||||
|
box-shadow: 0 20px 40px rgba(5, 8, 10, 0.45);
|
||||||
|
animation: rise 0.45s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: rgba(44, 185, 198, 0.15);
|
||||||
|
border: 1px solid rgba(44, 185, 198, 0.4);
|
||||||
|
color: #d7fbff;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.75rem;
|
letter-spacing: 0.06em;
|
||||||
letter-spacing: 0.2rem;
|
|
||||||
color: #8b9cff;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.banner {
|
||||||
font-size: 2.4rem;
|
padding: 14px 18px;
|
||||||
margin: 0 0 12px;
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(244, 184, 96, 0.35);
|
||||||
|
background: rgba(244, 184, 96, 0.12);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #ffe3b0;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subhead {
|
.muted {
|
||||||
margin: 0;
|
color: var(--muted);
|
||||||
max-width: 520px;
|
|
||||||
color: #b7c0ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
a {
|
||||||
background: rgba(17, 23, 40, 0.95);
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 24px 60px;
|
||||||
|
position: relative;
|
||||||
|
animation: rise 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
background: rgba(16, 24, 32, 0.9);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 24px;
|
border: 1px solid var(--border);
|
||||||
border: 1px solid rgba(118, 135, 255, 0.2);
|
margin-bottom: 22px;
|
||||||
|
box-shadow: 0 14px 30px rgba(5, 8, 10, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), #58d6c7);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #031417;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.45);
|
||||||
|
background: rgba(255, 107, 107, 0.12);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__header {
|
.toast {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(16, 24, 32, 0.92);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--success {
|
||||||
|
border-color: rgba(59, 165, 93, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
border-color: rgba(237, 66, 69, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: rgba(16, 24, 32, 0.9);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(44, 185, 198, 0.08);
|
||||||
|
animation: rise 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar input[type='search'] {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #111a22;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(21, 31, 39, 0.9);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__content {
|
.guild-item__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-item img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-item span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-item.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-item:hover {
|
||||||
|
border-color: rgba(44, 185, 198, 0.5);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--ok {
|
||||||
|
border-color: rgba(59, 165, 93, 0.5);
|
||||||
|
color: #d6f5de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--ok .status-pill__dot {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--warn {
|
||||||
|
border-color: rgba(246, 195, 67, 0.5);
|
||||||
|
color: #ffe8b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--warn .status-pill__dot {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.columns {
|
.guild-header {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
padding: 18px;
|
||||||
|
background: rgba(16, 24, 32, 0.9);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(244, 184, 96, 0.06);
|
||||||
|
animation: rise 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-header__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-header__info img {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-header__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--ok {
|
||||||
|
border-color: rgba(59, 165, 93, 0.5);
|
||||||
|
color: #d6f5de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--warn {
|
||||||
|
border-color: rgba(246, 195, 67, 0.5);
|
||||||
|
color: #ffe8b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(16, 24, 32, 0.9);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: linear-gradient(135deg, var(--primary), #58d6c7);
|
||||||
|
color: #041316;
|
||||||
|
box-shadow: 0 10px 18px rgba(44, 185, 198, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: rgba(12, 15, 26, 0.9);
|
background: rgba(16, 24, 32, 0.9);
|
||||||
border-radius: 14px;
|
border-radius: 18px;
|
||||||
padding: 16px;
|
border: 1px solid var(--border);
|
||||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
padding: 18px;
|
||||||
|
box-shadow: 0 18px 30px rgba(5, 8, 10, 0.35);
|
||||||
|
animation: rise 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header--split {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(244, 184, 96, 0.5);
|
||||||
|
background: rgba(244, 184, 96, 0.14);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #111a22;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-picker {
|
||||||
|
background: #111a22;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.multi-picker__search {
|
||||||
padding: 10px 18px;
|
padding: 8px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(118, 135, 255, 0.3);
|
border: 1px solid var(--border);
|
||||||
background: rgba(16, 20, 35, 0.8);
|
background: #0f1820;
|
||||||
color: #f5f7ff;
|
color: var(--text);
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.multi-picker__list {
|
||||||
transform: translateY(-1px);
|
max-height: 180px;
|
||||||
box-shadow: 0 8px 20px rgba(34, 44, 90, 0.4);
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-picker__list label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(21, 31, 39, 0.9);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--primary {
|
.button--primary {
|
||||||
background: linear-gradient(135deg, #5f6bff, #8a5bff);
|
background: linear-gradient(135deg, var(--primary), #58d6c7);
|
||||||
border: none;
|
border-color: var(--primary);
|
||||||
|
color: #041316;
|
||||||
|
box-shadow: 0 10px 18px rgba(44, 185, 198, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark), #3cc0b1);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--ghost {
|
.button--ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
select,
|
.button:hover {
|
||||||
input,
|
border-color: rgba(44, 185, 198, 0.35);
|
||||||
textarea {
|
transform: translateY(-1px);
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(118, 135, 255, 0.3);
|
|
||||||
background: rgba(6, 8, 16, 0.6);
|
|
||||||
color: #eef2ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.button:focus-visible,
|
||||||
min-height: 140px;
|
input:focus-visible,
|
||||||
resize: vertical;
|
select:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
.tab:focus-visible {
|
||||||
|
outline: 2px solid rgba(44, 185, 198, 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.button-row {
|
||||||
list-style: none;
|
display: flex;
|
||||||
padding: 0;
|
gap: 12px;
|
||||||
margin: 0;
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card {
|
||||||
|
background: rgba(21, 31, 39, 0.95);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:hover {
|
||||||
|
border-color: rgba(44, 185, 198, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #111a22;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li {
|
.member-item {
|
||||||
background: rgba(16, 20, 35, 0.6);
|
display: flex;
|
||||||
padding: 8px 12px;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
border: 1px solid transparent;
|
||||||
|
background: var(--card);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.member-item__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__id {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: white;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item {
|
||||||
|
background: rgba(21, 31, 39, 0.9);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.warnings-layout {
|
||||||
color: #9aa5e3;
|
display: grid;
|
||||||
margin: 0;
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
.warnings-pane {
|
||||||
.app__header {
|
background: rgba(21, 31, 39, 0.9);
|
||||||
|
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: rgba(16, 24, 32, 0.95);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 16px 30px rgba(5, 8, 10, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-indicator::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: #111a22;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list,
|
||||||
|
.skeleton-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #16222a 25%, #1d2b34 37%, #16222a 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 12, 18, 0.7);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 40;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__content {
|
||||||
|
background: rgba(16, 24, 32, 0.98);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 20px;
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 24px 40px rgba(5, 8, 10, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__content select {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #111a22;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card--disabled,
|
||||||
|
.panel--disabled,
|
||||||
|
.multi-picker--disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
strong {
|
||||||
|
font-family: 'Fraunces', 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-7
@@ -6,13 +6,50 @@ const DEFAULT_GUILD_PERMISSIONS = {
|
|||||||
commandPermissions: {},
|
commandPermissions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePermissionConfig = (config = {}) => ({
|
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
|
||||||
...DEFAULT_GUILD_PERMISSIONS,
|
|
||||||
...config,
|
const normalizeIdArray = (value) => {
|
||||||
adminRoleIds: Array.isArray(config.adminRoleIds) ? config.adminRoleIds : [],
|
if (!Array.isArray(value)) {
|
||||||
adminUserIds: Array.isArray(config.adminUserIds) ? config.adminUserIds : [],
|
return [];
|
||||||
commandPermissions: config.commandPermissions ?? {},
|
}
|
||||||
});
|
|
||||||
|
const normalized = value
|
||||||
|
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
return Array.from(new Set(normalized));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeCommandPermissions = (commandPermissions = {}) => {
|
||||||
|
if (!commandPermissions || typeof commandPermissions !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = {};
|
||||||
|
for (const [commandName, commandConfig] of Object.entries(commandPermissions)) {
|
||||||
|
if (!COMMAND_NAME_REGEX.test(commandName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized[commandName] = {
|
||||||
|
allowedRoleIds: normalizeIdArray(commandConfig?.allowedRoleIds),
|
||||||
|
allowedUserIds: normalizeIdArray(commandConfig?.allowedUserIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePermissionConfig = (config = {}) => {
|
||||||
|
const safeConfig = config && typeof config === 'object' ? config : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_GUILD_PERMISSIONS,
|
||||||
|
adminRoleIds: normalizeIdArray(safeConfig.adminRoleIds),
|
||||||
|
adminUserIds: normalizeIdArray(safeConfig.adminUserIds),
|
||||||
|
commandPermissions: sanitizeCommandPermissions(safeConfig.commandPermissions),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const memberHasRole = (member, roleIds) =>
|
const memberHasRole = (member, roleIds) =>
|
||||||
roleIds.some((roleId) => member.roles?.cache?.has(roleId));
|
roleIds.some((roleId) => member.roles?.cache?.has(roleId));
|
||||||
|
|||||||
@@ -40,7 +40,21 @@ const getWarnings = async ({ guildId, userId }) => {
|
|||||||
return record?.warnings ?? [];
|
return record?.warnings ?? [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearWarnings = async ({ guildId, userId }) => {
|
||||||
|
const collection = await getWarningsCollection();
|
||||||
|
const result = await collection.findOneAndUpdate(
|
||||||
|
{ guildId, userId },
|
||||||
|
{ $set: { warnings: [] } },
|
||||||
|
{ returnDocument: 'after', upsert: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateStrikes({ guildId, userId, strikes: 0 });
|
||||||
|
|
||||||
|
return result.value?.warnings ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
addWarning,
|
addWarning,
|
||||||
getWarnings,
|
getWarnings,
|
||||||
|
clearWarnings,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user