Add dashboard UI and secure guild permissions
This commit is contained in:
+60
-188
@@ -1,39 +1,9 @@
|
||||
const state = {
|
||||
guilds: [],
|
||||
selectedGuildId: null,
|
||||
};
|
||||
|
||||
const elements = {
|
||||
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 fetchJson = async (url, options = {}) => {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -41,183 +11,85 @@ const fetchJson = async (url, options) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const message = body.error || `Request failed with status ${response.status}`;
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
error.details = body.details;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(() => ({}));
|
||||
};
|
||||
|
||||
const loadGuilds = async () => {
|
||||
const data = await fetchJson('/api/guilds');
|
||||
state.guilds = data.guilds || [];
|
||||
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) {
|
||||
const setBanner = (bannerEl, message, type = 'error') => {
|
||||
if (!bannerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [permissionsData, rolesData] = await Promise.all([
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`),
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/roles`),
|
||||
]);
|
||||
|
||||
elements.permissionsEditor.value = JSON.stringify(permissionsData.permissions || {}, null, 2);
|
||||
renderRoles(rolesData.roles || []);
|
||||
bannerEl.textContent = message;
|
||||
bannerEl.classList.remove('banner--error', 'banner--success');
|
||||
bannerEl.classList.add(type === 'success' ? 'banner--success' : 'banner--error');
|
||||
bannerEl.style.display = 'block';
|
||||
};
|
||||
|
||||
const updatePermissions = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
const clearBanner = (bannerEl) => {
|
||||
if (!bannerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawValue = elements.permissionsEditor.value.trim();
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(rawValue);
|
||||
} catch (error) {
|
||||
elements.permissionsEditor.focus();
|
||||
throw new Error('Permissions JSON is invalid.');
|
||||
}
|
||||
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
elements.permissionsEditor.value = JSON.stringify(data.permissions || {}, null, 2);
|
||||
bannerEl.textContent = '';
|
||||
bannerEl.style.display = 'none';
|
||||
};
|
||||
|
||||
const loadWarnings = async (userId) => {
|
||||
if (!state.selectedGuildId || !userId) {
|
||||
return;
|
||||
const formatUserDisplay = (user) => {
|
||||
if (!user) {
|
||||
return 'Not signed in';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
return `${user.username}#${user.discriminator ?? '0000'}`;
|
||||
};
|
||||
|
||||
const addWarning = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
const initIndexPage = async () => {
|
||||
const sessionStatus = document.getElementById('sessionStatus');
|
||||
const loginButton = document.getElementById('loginButton');
|
||||
const dashboardButton = document.getElementById('dashboardButton');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
const banner = document.getElementById('banner');
|
||||
|
||||
if (!sessionStatus || !loginButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = elements.newWarningUserId.value.trim();
|
||||
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) => {
|
||||
elements.warningResult.textContent = message;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
elements.loadWarningsButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadWarnings(elements.warningUserId.value.trim());
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.addWarningButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await addWarning();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await loadGuilds();
|
||||
setStatus('Authenticated. Choose a guild to get started.', true);
|
||||
const data = await fetchJson('/api/me');
|
||||
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
||||
loginButton.hidden = true;
|
||||
dashboardButton.hidden = false;
|
||||
logoutButton.hidden = false;
|
||||
clearBanner(banner);
|
||||
} 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;
|
||||
setBanner(banner, 'Log in with Discord to access the dashboard.', 'error');
|
||||
}
|
||||
|
||||
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) {
|
||||
setBanner(banner, error.message || 'Failed to log out.', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
init();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initIndexPage();
|
||||
});
|
||||
|
||||
window.dashboardApi = {
|
||||
fetchJson,
|
||||
setBanner,
|
||||
clearBanner,
|
||||
formatUserDisplay,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<!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">
|
||||
<header class="topbar">
|
||||
<div class="topbar__user">
|
||||
<span class="badge">Guild dashboard</span>
|
||||
<strong id="userDisplay">Loading session...</strong>
|
||||
<span class="muted" id="sessionExpiry"></span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="button" href="/">Home</a>
|
||||
<button class="button button--ghost" id="logoutButton">Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="banner" id="banner"></div>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="panel">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<h2>Manageable guilds</h2>
|
||||
</div>
|
||||
<div class="guild-list" id="guildList"></div>
|
||||
<div class="empty-state" id="guildEmpty" hidden>
|
||||
No manageable guilds found. Make sure you have Manage Server permissions.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="permissions">Permissions</button>
|
||||
<button class="tab" data-tab="warnings">Warnings</button>
|
||||
</div>
|
||||
|
||||
<section class="panel" id="permissionsPanel">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<div>
|
||||
<h2>Admin access</h2>
|
||||
<p class="muted">Choose who can edit the bot configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Admin roles</span>
|
||||
<select class="multi-select" id="adminRolesSelect" multiple></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Add admin user ID</span>
|
||||
<div class="button-row">
|
||||
<input type="text" id="adminUserInput" placeholder="123456789" />
|
||||
<button class="button" id="addAdminUserButton">Add user</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="chip-list" id="adminUserChips"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<div>
|
||||
<h2>Command permissions</h2>
|
||||
<p class="muted">Configure who can run each command. Empty restrictions allow everyone.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<input type="text" id="newCommandInput" placeholder="new-command" />
|
||||
<button class="button" id="addCommandButton">Add command</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" id="commandsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Command</th>
|
||||
<th>Allowed roles</th>
|
||||
<th>Allowed users</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="button button--primary" id="savePermissionsButton">Save permissions</button>
|
||||
<span class="muted" id="permissionsStatus"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="warningsPanel" hidden>
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<div>
|
||||
<h2>Warnings lookup</h2>
|
||||
<p class="muted">Fetch recent warnings for a user.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>User ID</span>
|
||||
<div class="button-row">
|
||||
<input type="text" id="warningsUserInput" placeholder="123456789" />
|
||||
<button class="button" id="fetchWarningsButton">Fetch warnings</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div>
|
||||
<ul id="warningsList"></ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<div>
|
||||
<h2>Add warning</h2>
|
||||
<p class="muted">Record a new warning and sync strikes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>User ID</span>
|
||||
<input type="text" id="newWarningUserInput" placeholder="123456789" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reason</span>
|
||||
<textarea id="warningReasonInput" placeholder="Reason for warning"></textarea>
|
||||
</label>
|
||||
<button class="button button--primary" id="addWarningButton">Add warning</button>
|
||||
<p class="muted" id="warningStatus"></p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,465 @@
|
||||
const state = {
|
||||
guilds: [],
|
||||
selectedGuildId: null,
|
||||
roles: [],
|
||||
permissions: null,
|
||||
commands: [],
|
||||
};
|
||||
|
||||
const elements = {
|
||||
banner: document.getElementById('banner'),
|
||||
userDisplay: document.getElementById('userDisplay'),
|
||||
sessionExpiry: document.getElementById('sessionExpiry'),
|
||||
logoutButton: document.getElementById('logoutButton'),
|
||||
guildList: document.getElementById('guildList'),
|
||||
guildEmpty: document.getElementById('guildEmpty'),
|
||||
tabs: document.querySelectorAll('.tab'),
|
||||
permissionsPanel: document.getElementById('permissionsPanel'),
|
||||
warningsPanel: document.getElementById('warningsPanel'),
|
||||
adminRolesSelect: document.getElementById('adminRolesSelect'),
|
||||
adminUserInput: document.getElementById('adminUserInput'),
|
||||
addAdminUserButton: document.getElementById('addAdminUserButton'),
|
||||
adminUserChips: document.getElementById('adminUserChips'),
|
||||
newCommandInput: document.getElementById('newCommandInput'),
|
||||
addCommandButton: document.getElementById('addCommandButton'),
|
||||
commandsTable: document.getElementById('commandsTable'),
|
||||
savePermissionsButton: document.getElementById('savePermissionsButton'),
|
||||
permissionsStatus: document.getElementById('permissionsStatus'),
|
||||
warningsUserInput: document.getElementById('warningsUserInput'),
|
||||
fetchWarningsButton: document.getElementById('fetchWarningsButton'),
|
||||
warningsList: document.getElementById('warningsList'),
|
||||
warningsEmpty: document.getElementById('warningsEmpty'),
|
||||
newWarningUserInput: document.getElementById('newWarningUserInput'),
|
||||
warningReasonInput: document.getElementById('warningReasonInput'),
|
||||
addWarningButton: document.getElementById('addWarningButton'),
|
||||
warningStatus: document.getElementById('warningStatus'),
|
||||
};
|
||||
|
||||
const {fetchJson, setBanner, clearBanner, formatUserDisplay} = window.dashboardApi;
|
||||
|
||||
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
|
||||
|
||||
const setLoading = (isLoading) => {
|
||||
document.body.classList.toggle('loading', isLoading);
|
||||
};
|
||||
|
||||
const renderGuilds = () => {
|
||||
elements.guildList.innerHTML = '';
|
||||
if (state.guilds.length === 0) {
|
||||
elements.guildEmpty.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.guildEmpty.hidden = true;
|
||||
state.guilds.forEach((guild) => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = `guild-item${state.selectedGuildId === guild.id ? ' active' : ''}`;
|
||||
item.dataset.guildId = guild.id;
|
||||
const icon = document.createElement('img');
|
||||
if (guild.icon) {
|
||||
icon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
|
||||
icon.alt = `${guild.name} icon`;
|
||||
} else {
|
||||
icon.alt = '';
|
||||
}
|
||||
const name = document.createElement('span');
|
||||
name.textContent = guild.name;
|
||||
item.appendChild(icon);
|
||||
item.appendChild(name);
|
||||
item.addEventListener('click', () => selectGuild(guild.id));
|
||||
elements.guildList.appendChild(item);
|
||||
});
|
||||
};
|
||||
|
||||
const renderRolesSelect = () => {
|
||||
elements.adminRolesSelect.innerHTML = '';
|
||||
state.roles
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.forEach((role) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = role.id;
|
||||
option.textContent = role.name;
|
||||
elements.adminRolesSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (state.permissions) {
|
||||
const adminRoleIds = new Set(state.permissions.adminRoleIds || []);
|
||||
Array.from(elements.adminRolesSelect.options).forEach((option) => {
|
||||
option.selected = adminRoleIds.has(option.value);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderAdminUsers = () => {
|
||||
elements.adminUserChips.innerHTML = '';
|
||||
const adminUserIds = state.permissions?.adminUserIds || [];
|
||||
if (adminUserIds.length === 0) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'muted';
|
||||
empty.textContent = 'No admin users added.';
|
||||
elements.adminUserChips.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
adminUserIds.forEach((userId) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.textContent = userId;
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = '✕';
|
||||
removeButton.addEventListener('click', () => {
|
||||
state.permissions.adminUserIds = adminUserIds.filter((id) => id !== userId);
|
||||
renderAdminUsers();
|
||||
});
|
||||
chip.appendChild(removeButton);
|
||||
elements.adminUserChips.appendChild(chip);
|
||||
});
|
||||
};
|
||||
|
||||
const ensureCommandEntry = (commandName) => {
|
||||
if (!state.permissions.commandPermissions[commandName]) {
|
||||
state.permissions.commandPermissions[commandName] = {
|
||||
allowedRoleIds: [],
|
||||
allowedUserIds: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const renderCommandsTable = () => {
|
||||
const tbody = elements.commandsTable.querySelector('tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
const commands = [...new Set(state.commands)].sort();
|
||||
if (commands.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 4;
|
||||
cell.className = 'muted';
|
||||
cell.textContent = 'No commands configured yet. Add one above.';
|
||||
row.appendChild(cell);
|
||||
tbody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
commands.forEach((commandName) => {
|
||||
ensureCommandEntry(commandName);
|
||||
const commandConfig = state.permissions.commandPermissions[commandName];
|
||||
const row = document.createElement('tr');
|
||||
|
||||
const nameCell = document.createElement('td');
|
||||
nameCell.textContent = commandName;
|
||||
|
||||
const roleCell = document.createElement('td');
|
||||
const roleSelect = document.createElement('select');
|
||||
roleSelect.multiple = true;
|
||||
roleSelect.className = 'multi-select';
|
||||
state.roles
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.forEach((role) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = role.id;
|
||||
option.textContent = role.name;
|
||||
option.selected = commandConfig.allowedRoleIds.includes(role.id);
|
||||
roleSelect.appendChild(option);
|
||||
});
|
||||
roleSelect.addEventListener('change', () => {
|
||||
commandConfig.allowedRoleIds = Array.from(roleSelect.selectedOptions).map((opt) => opt.value);
|
||||
});
|
||||
roleCell.appendChild(roleSelect);
|
||||
|
||||
const userCell = document.createElement('td');
|
||||
const userInput = document.createElement('input');
|
||||
userInput.type = 'text';
|
||||
userInput.placeholder = 'User ID';
|
||||
const addUserButton = document.createElement('button');
|
||||
addUserButton.type = 'button';
|
||||
addUserButton.className = 'button';
|
||||
addUserButton.textContent = 'Add';
|
||||
const chipList = document.createElement('div');
|
||||
chipList.className = 'chip-list';
|
||||
|
||||
const renderUserChips = () => {
|
||||
chipList.innerHTML = '';
|
||||
if (commandConfig.allowedUserIds.length === 0) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'muted';
|
||||
empty.textContent = 'No users added.';
|
||||
chipList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
commandConfig.allowedUserIds.forEach((userId) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.textContent = userId;
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = '✕';
|
||||
remove.addEventListener('click', () => {
|
||||
commandConfig.allowedUserIds = commandConfig.allowedUserIds.filter((id) => id !== userId);
|
||||
renderUserChips();
|
||||
});
|
||||
chip.appendChild(remove);
|
||||
chipList.appendChild(chip);
|
||||
});
|
||||
};
|
||||
|
||||
addUserButton.addEventListener('click', () => {
|
||||
const value = userInput.value.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!commandConfig.allowedUserIds.includes(value)) {
|
||||
commandConfig.allowedUserIds.push(value);
|
||||
renderUserChips();
|
||||
}
|
||||
userInput.value = '';
|
||||
});
|
||||
|
||||
const userControls = document.createElement('div');
|
||||
userControls.className = 'button-row';
|
||||
userControls.appendChild(userInput);
|
||||
userControls.appendChild(addUserButton);
|
||||
userCell.appendChild(userControls);
|
||||
userCell.appendChild(chipList);
|
||||
renderUserChips();
|
||||
|
||||
const actionCell = document.createElement('td');
|
||||
const clearButton = document.createElement('button');
|
||||
clearButton.type = 'button';
|
||||
clearButton.className = 'button button--ghost';
|
||||
clearButton.textContent = 'Clear restrictions';
|
||||
clearButton.addEventListener('click', () => {
|
||||
commandConfig.allowedRoleIds = [];
|
||||
commandConfig.allowedUserIds = [];
|
||||
renderCommandsTable();
|
||||
});
|
||||
actionCell.appendChild(clearButton);
|
||||
|
||||
row.appendChild(nameCell);
|
||||
row.appendChild(roleCell);
|
||||
row.appendChild(userCell);
|
||||
row.appendChild(actionCell);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const loadSession = async () => {
|
||||
const data = await fetchJson('/api/me');
|
||||
elements.userDisplay.textContent = formatUserDisplay(data.user);
|
||||
if (data.expiresAt) {
|
||||
const expires = new Date(data.expiresAt);
|
||||
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
const loadGuilds = async () => {
|
||||
const data = await fetchJson('/api/guilds');
|
||||
state.guilds = data.guilds || [];
|
||||
if (state.guilds.length > 0) {
|
||||
state.selectedGuildId = state.guilds[0].id;
|
||||
}
|
||||
renderGuilds();
|
||||
};
|
||||
|
||||
const loadGuildDetails = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const [permissionsData, rolesData, commandsData] = await Promise.all([
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`),
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/roles`),
|
||||
fetchJson(`/api/guilds/${state.selectedGuildId}/commands`),
|
||||
]);
|
||||
|
||||
state.permissions = permissionsData.permissions || {
|
||||
adminRoleIds: [],
|
||||
adminUserIds: [],
|
||||
commandPermissions: {},
|
||||
};
|
||||
state.permissions.commandPermissions = state.permissions.commandPermissions || {};
|
||||
state.roles = rolesData.roles || [];
|
||||
state.commands = commandsData.commands || [];
|
||||
|
||||
Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
|
||||
if (!state.commands.includes(commandName)) {
|
||||
state.commands.push(commandName);
|
||||
}
|
||||
});
|
||||
|
||||
renderRolesSelect();
|
||||
renderAdminUsers();
|
||||
renderCommandsTable();
|
||||
elements.permissionsStatus.textContent = '';
|
||||
clearBanner(elements.banner);
|
||||
} catch (error) {
|
||||
setBanner(elements.banner, error.message || 'Failed to load guild data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectGuild = async (guildId) => {
|
||||
state.selectedGuildId = guildId;
|
||||
renderGuilds();
|
||||
await loadGuildDetails();
|
||||
};
|
||||
|
||||
const addAdminUser = () => {
|
||||
const value = elements.adminUserInput.value.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!state.permissions.adminUserIds.includes(value)) {
|
||||
state.permissions.adminUserIds.push(value);
|
||||
}
|
||||
elements.adminUserInput.value = '';
|
||||
renderAdminUsers();
|
||||
};
|
||||
|
||||
const addCommand = () => {
|
||||
const value = elements.newCommandInput.value.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!COMMAND_NAME_REGEX.test(value)) {
|
||||
setBanner(elements.banner, 'Command name must be 1-32 characters and use letters, numbers, - or _.');
|
||||
return;
|
||||
}
|
||||
if (!state.commands.includes(value)) {
|
||||
state.commands.push(value);
|
||||
}
|
||||
elements.newCommandInput.value = '';
|
||||
renderCommandsTable();
|
||||
};
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!state.selectedGuildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminRoleIds = Array.from(elements.adminRolesSelect.selectedOptions).map((option) => option.value);
|
||||
state.permissions.adminRoleIds = adminRoleIds;
|
||||
|
||||
const payload = {
|
||||
adminRoleIds: state.permissions.adminRoleIds,
|
||||
adminUserIds: state.permissions.adminUserIds,
|
||||
commandPermissions: state.permissions.commandPermissions,
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.permissions = data.permissions;
|
||||
elements.permissionsStatus.textContent = 'Permissions saved successfully.';
|
||||
setBanner(elements.banner, 'Permissions updated successfully.', 'success');
|
||||
renderRolesSelect();
|
||||
renderAdminUsers();
|
||||
renderCommandsTable();
|
||||
} catch (error) {
|
||||
setBanner(elements.banner, error.message || 'Failed to save permissions.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (tabName) => {
|
||||
elements.tabs.forEach((tab) => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
elements.permissionsPanel.hidden = tabName !== 'permissions';
|
||||
elements.warningsPanel.hidden = tabName !== 'warnings';
|
||||
};
|
||||
|
||||
const fetchWarnings = async () => {
|
||||
const userId = elements.warningsUserInput.value.trim();
|
||||
if (!userId) {
|
||||
setBanner(elements.banner, 'Enter a user ID to fetch warnings.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`);
|
||||
const warnings = data.warnings || [];
|
||||
elements.warningsList.innerHTML = '';
|
||||
if (warnings.length === 0) {
|
||||
elements.warningsEmpty.hidden = false;
|
||||
} else {
|
||||
elements.warningsEmpty.hidden = true;
|
||||
warnings.forEach((warning) => {
|
||||
const item = document.createElement('li');
|
||||
const dateText = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
|
||||
item.textContent = `${warning.reason} (Moderator: ${warning.moderatorId || 'Unknown'}) — ${dateText}`;
|
||||
elements.warningsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
clearBanner(elements.banner);
|
||||
} catch (error) {
|
||||
setBanner(elements.banner, error.message || 'Failed to fetch warnings.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addWarning = async () => {
|
||||
const userId = elements.newWarningUserInput.value.trim();
|
||||
const reason = elements.warningReasonInput.value.trim();
|
||||
if (!userId || !reason) {
|
||||
elements.warningStatus.textContent = 'User ID and reason are required.';
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({reason}),
|
||||
});
|
||||
elements.warningStatus.textContent = `Warning added. Total warnings: ${data.warnings?.length || 0}`;
|
||||
elements.warningReasonInput.value = '';
|
||||
clearBanner(elements.banner);
|
||||
} catch (error) {
|
||||
setBanner(elements.banner, error.message || 'Failed to add warning.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initDashboard = async () => {
|
||||
try {
|
||||
await loadSession();
|
||||
await loadGuilds();
|
||||
if (state.selectedGuildId) {
|
||||
await loadGuildDetails();
|
||||
}
|
||||
} catch (error) {
|
||||
setBanner(elements.banner, 'Please log in to access the dashboard.');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.logoutButton.addEventListener('click', async () => {
|
||||
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
elements.tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
||||
});
|
||||
|
||||
elements.addAdminUserButton.addEventListener('click', addAdminUser);
|
||||
elements.addCommandButton.addEventListener('click', addCommand);
|
||||
elements.savePermissionsButton.addEventListener('click', savePermissions);
|
||||
elements.fetchWarningsButton.addEventListener('click', fetchWarnings);
|
||||
elements.addWarningButton.addEventListener('click', addWarning);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initDashboard();
|
||||
});
|
||||
+14
-78
@@ -8,90 +8,26 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="app__header">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Discord Bot</p>
|
||||
<h1>Moderation Dashboard</h1>
|
||||
<p class="subhead">
|
||||
Manage permissions, review roles, and track warnings without leaving your browser.
|
||||
</p>
|
||||
<div class="badge">Discord Bot Dashboard</div>
|
||||
<h1>Manage your moderation settings</h1>
|
||||
<p class="muted">Configure permissions and warnings without touching JSON.</p>
|
||||
</div>
|
||||
<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>
|
||||
<button class="button button--primary" id="loginButton">Log in with Discord</button>
|
||||
</header>
|
||||
|
||||
<section class="card" id="sessionCard">
|
||||
<div>
|
||||
<h2>Session</h2>
|
||||
<p id="sessionStatus">Checking your session...</p>
|
||||
</div>
|
||||
<button class="button button--ghost" id="logoutButton" hidden>Log out</button>
|
||||
</section>
|
||||
<div class="banner" id="banner"></div>
|
||||
|
||||
<section class="card" id="guildCard" hidden>
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<h2>Your guilds</h2>
|
||||
<p>Select a guild to manage permission overrides and warnings.</p>
|
||||
</div>
|
||||
<select id="guildSelect"></select>
|
||||
<section class="panel">
|
||||
<h2>Session status</h2>
|
||||
<p class="muted" id="sessionStatus">Checking your session...</p>
|
||||
<div class="button-row" style="margin-top: 16px;">
|
||||
<button class="button button--ghost" id="logoutButton" hidden>Log out</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
+235
-122
@@ -1,9 +1,16 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
background: #0e111b;
|
||||
color: #eef2ff;
|
||||
color-scheme: light;
|
||||
--bg: #0f1218;
|
||||
--panel: #171b26;
|
||||
--card: #1e2332;
|
||||
--border: #2d3447;
|
||||
--text: #f7f7f9;
|
||||
--muted: #b3b8c7;
|
||||
--primary: #5865f2;
|
||||
--primary-dark: #4752c4;
|
||||
--success: #3ba55d;
|
||||
--danger: #ed4245;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -13,164 +20,270 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #1d2542 0%, #0e111b 55%);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 24px 60px;
|
||||
}
|
||||
|
||||
.app__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
background: rgba(17, 23, 40, 0.9);
|
||||
border: 1px solid rgba(118, 135, 255, 0.2);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.2rem;
|
||||
color: #8b9cff;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
color: #b7c0ff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(17, 23, 40, 0.95);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card__content {
|
||||
.topbar__user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(88, 101, 242, 0.2);
|
||||
color: #dfe1ff;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #22283a;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner--error {
|
||||
border-color: rgba(237, 66, 69, 0.4);
|
||||
background: rgba(237, 66, 69, 0.15);
|
||||
}
|
||||
|
||||
.banner--success {
|
||||
border-color: rgba(59, 165, 93, 0.4);
|
||||
background: rgba(59, 165, 93, 0.15);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(12, 15, 26, 0.9);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
||||
}
|
||||
|
||||
.guild-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.3);
|
||||
background: rgba(16, 20, 35, 0.8);
|
||||
color: #f5f7ff;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(34, 44, 90, 0.4);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: linear-gradient(135deg, #5f6bff, #8a5bff);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
select,
|
||||
input,
|
||||
textarea {
|
||||
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 {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.guild-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list li {
|
||||
background: rgba(16, 20, 35, 0.6);
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(118, 135, 255, 0.15);
|
||||
.guild-item img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.guild-item.active {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #131826;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.multi-select {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 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;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9aa5e3;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.loading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
background: #151a27;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user