Files
Discord_bot/public/dashboard.js
T

1077 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
guilds: [],
filteredGuilds: [],
selectedGuildId: null,
roles: [],
permissions: null,
commands: [],
botGuildIds: new Set(),
botInstalled: false,
dirty: false,
originalPermissions: null,
selectedWarningUser: null,
};
const elements = {
errorBanner: document.getElementById('errorBanner'),
userDisplay: document.getElementById('userDisplay'),
sessionExpiry: document.getElementById('sessionExpiry'),
logoutButton: document.getElementById('logoutButton'),
guildList: document.getElementById('guildList'),
guildListSkeleton: document.getElementById('guildListSkeleton'),
guildEmpty: document.getElementById('guildEmpty'),
guildSearch: document.getElementById('guildSearch'),
guildHeaderIcon: document.getElementById('guildHeaderIcon'),
guildHeaderName: document.getElementById('guildHeaderName'),
guildStatusBadge: document.getElementById('guildStatusBadge'),
guildStatusNote: document.getElementById('guildStatusNote'),
inviteButton: document.getElementById('inviteButton'),
refreshBotButton: document.getElementById('refreshBotButton'),
tabs: document.querySelectorAll('.tab'),
settingsPanel: document.getElementById('settingsPanel'),
permissionsPanel: document.getElementById('permissionsPanel'),
warningsPanel: document.getElementById('warningsPanel'),
adminRolesPicker: document.getElementById('adminRolesPicker'),
adminUserInput: document.getElementById('adminUserInput'),
addAdminUserButton: document.getElementById('addAdminUserButton'),
adminUserChips: document.getElementById('adminUserChips'),
newCommandInput: document.getElementById('newCommandInput'),
addCommandButton: document.getElementById('addCommandButton'),
commandsList: document.getElementById('commandsList'),
commandsEmpty: document.getElementById('commandsEmpty'),
clearAllCommands: document.getElementById('clearAllCommands'),
copyFromGuild: document.getElementById('copyFromGuild'),
savePermissionsButton: document.getElementById('savePermissionsButton'),
discardChangesButton: document.getElementById('discardChangesButton'),
saveBar: document.getElementById('saveBar'),
botInactiveCallout: document.getElementById('botInactiveCallout'),
warningsUserInput: document.getElementById('warningsUserInput'),
warningsUserSearch: document.getElementById('warningsUserSearch'),
warningsUserResults: document.getElementById('warningsUserResults'),
warningsSelectedUser: document.getElementById('warningsSelectedUser'),
fetchWarningsButton: document.getElementById('fetchWarningsButton'),
clearWarningsButton: document.getElementById('clearWarningsButton'),
warningsList: document.getElementById('warningsList'),
warningsEmpty: document.getElementById('warningsEmpty'),
warningsSpinner: document.getElementById('warningsSpinner'),
newWarningUserInput: document.getElementById('newWarningUserInput'),
warningReasonInput: document.getElementById('warningReasonInput'),
warningCharCount: document.getElementById('warningCharCount'),
addWarningButton: document.getElementById('addWarningButton'),
warningStatus: document.getElementById('warningStatus'),
contentSkeleton: document.getElementById('contentSkeleton'),
copyModal: document.getElementById('copyModal'),
copyGuildSelect: document.getElementById('copyGuildSelect'),
cancelCopyButton: document.getElementById('cancelCopyButton'),
confirmCopyButton: document.getElementById('confirmCopyButton'),
};
const dashboardApi = window.dashboardApi;
const fetchDashboardJson = dashboardApi.fetchJson;
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
const clonePermissions = (permissions) => JSON.parse(JSON.stringify(permissions || {}));
const normalizePermissions = (permissions = {}) => ({
adminRoleIds: Array.isArray(permissions.adminRoleIds) ? permissions.adminRoleIds : [],
adminUserIds: Array.isArray(permissions.adminUserIds) ? permissions.adminUserIds : [],
commandPermissions: permissions.commandPermissions || {},
});
const setLoadingGuilds = (isLoading) => {
elements.guildList.hidden = isLoading;
elements.guildListSkeleton.hidden = !isLoading;
};
const setLoadingDetails = (isLoading) => {
elements.contentSkeleton.hidden = !isLoading;
if (isLoading) {
elements.settingsPanel.hidden = true;
elements.permissionsPanel.hidden = true;
elements.warningsPanel.hidden = true;
return;
}
const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'settings';
elements.settingsPanel.hidden = activeTab !== 'settings';
elements.permissionsPanel.hidden = activeTab !== 'permissions';
elements.warningsPanel.hidden = activeTab !== 'warnings';
};
const setDirty = (isDirty) => {
state.dirty = isDirty;
elements.saveBar.hidden = !isDirty;
};
const parseIdList = (value) => {
if (!value) {
return [];
}
return Array.from(
new Set(
value
.split(/[\s,\n]+/)
.map((entry) => entry.trim())
.filter(Boolean),
),
);
};
const setError = (error) => {
if (!error) {
dashboardApi.clearErrorBanner(elements.errorBanner);
return;
}
dashboardApi.setErrorBanner(elements.errorBanner, error.message || 'Something went wrong.', error.payload || error);
};
const handleFetchError = (error, fallback) => {
if (error?.code === 'BOT_NOT_IN_GUILD') {
return;
}
setError(error || {message: fallback});
};
const updateGuildHeader = (guild) => {
if (!guild) {
elements.guildHeaderName.textContent = 'Select a guild';
elements.guildHeaderIcon.src = '';
elements.guildStatusBadge.textContent = '';
elements.guildStatusNote.textContent = '';
return;
}
elements.guildHeaderName.textContent = guild.name;
if (guild.icon) {
elements.guildHeaderIcon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
} else {
elements.guildHeaderIcon.removeAttribute('src');
}
if (state.botInstalled) {
elements.guildStatusBadge.textContent = 'Bot added';
elements.guildStatusBadge.className = 'status-badge status-badge--ok';
elements.guildStatusNote.textContent = 'Settings are active.';
} else {
elements.guildStatusBadge.textContent = 'Not added';
elements.guildStatusBadge.className = 'status-badge status-badge--warn';
elements.guildStatusNote.textContent = 'The bot isn’t in this server yet. Invite it to configure settings.';
}
};
const renderGuilds = () => {
elements.guildList.innerHTML = '';
if (state.filteredGuilds.length === 0) {
elements.guildEmpty.hidden = false;
return;
}
elements.guildEmpty.hidden = true;
state.filteredGuilds.forEach((guild) => {
const item = document.createElement('button');
item.type = 'button';
item.className = `guild-item${state.selectedGuildId === guild.id ? ' active' : ''}`;
item.dataset.guildId = guild.id;
const info = document.createElement('div');
info.className = 'guild-item__info';
const icon = document.createElement('img');
if (guild.icon) {
icon.src = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
icon.alt = `${guild.name} icon`;
} else {
icon.alt = '';
}
const name = document.createElement('span');
name.textContent = guild.name;
info.appendChild(icon);
info.appendChild(name);
const badge = document.createElement('span');
const isBotInGuild = state.botGuildIds.size === 0 ? null : state.botGuildIds.has(guild.id);
badge.className = `status-pill ${isBotInGuild ? 'status-pill--ok' : 'status-pill--warn'}`;
const dot = document.createElement('span');
dot.className = 'status-pill__dot';
const text = document.createElement('span');
text.textContent = isBotInGuild ? 'Bot added' : 'Not added';
badge.appendChild(dot);
badge.appendChild(text);
item.appendChild(info);
item.appendChild(badge);
item.addEventListener('click', () => selectGuild(guild.id));
elements.guildList.appendChild(item);
});
};
const renderRolePicker = (container, roles, selectedIds, onChange, options = {}) => {
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = `multi-picker${options.disabled ? ' multi-picker--disabled' : ''}`;
const search = document.createElement('input');
search.type = 'text';
search.className = 'multi-picker__search';
search.placeholder = 'Search roles';
const list = document.createElement('div');
list.className = 'multi-picker__list';
const chips = document.createElement('div');
chips.className = 'chip-list';
const renderChips = () => {
chips.innerHTML = '';
if (selectedIds.length === 0) {
const empty = document.createElement('span');
empty.className = 'muted';
empty.textContent = options.emptyMessage || 'No roles selected.';
chips.appendChild(empty);
return;
}
selectedIds.forEach((roleId) => {
const role = roles.find((entry) => entry.id === roleId);
if (!role) {
return;
}
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = role.name;
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = '✕';
removeButton.addEventListener('click', () => {
const next = selectedIds.filter((id) => id !== roleId);
selectedIds.splice(0, selectedIds.length, ...next);
onChange([...selectedIds]);
renderList(search.value);
renderChips();
});
chip.appendChild(removeButton);
chips.appendChild(chip);
});
};
const renderList = (query = '') => {
list.innerHTML = '';
const filteredRoles = roles
.filter((role) => role.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => b.position - a.position);
if (filteredRoles.length === 0) {
const empty = document.createElement('span');
empty.className = 'muted';
empty.textContent = 'No matching roles.';
list.appendChild(empty);
return;
}
filteredRoles.forEach((role) => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = selectedIds.includes(role.id);
checkbox.disabled = options.disabled;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
if (!selectedIds.includes(role.id)) {
selectedIds.push(role.id);
}
} else {
const next = selectedIds.filter((id) => id !== role.id);
selectedIds.splice(0, selectedIds.length, ...next);
}
onChange([...selectedIds]);
renderChips();
});
label.appendChild(checkbox);
label.appendChild(document.createTextNode(role.name));
list.appendChild(label);
});
};
search.addEventListener('input', () => renderList(search.value));
renderList();
renderChips();
wrapper.appendChild(search);
wrapper.appendChild(list);
wrapper.appendChild(chips);
container.appendChild(wrapper);
};
const renderAdminUsers = () => {
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);
setDirty(true);
renderAdminUsers();
});
chip.appendChild(removeButton);
elements.adminUserChips.appendChild(chip);
});
};
const formatMemberDisplay = (member) => {
const user = member?.user || {};
const displayName = member?.nick || user.global_name || user.username || 'Unknown user';
const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : '';
return `${displayName}${tag}`;
};
const setSelectedWarningUser = (member) => {
state.selectedWarningUser = member || null;
if (!member) {
elements.warningsSelectedUser.textContent = 'No user selected.';
return;
}
elements.warningsSelectedUser.textContent = `${formatMemberDisplay(member)} (${member.id})`;
elements.warningsUserInput.value = member.id;
elements.newWarningUserInput.value = member.id;
};
const setManualWarningUser = (userId) => {
state.selectedWarningUser = null;
if (!userId) {
elements.warningsSelectedUser.textContent = 'No user selected.';
return;
}
elements.warningsSelectedUser.textContent = `Using manual ID: ${userId}`;
};
const resetWarningSelection = () => {
setSelectedWarningUser(null);
elements.warningsUserResults.innerHTML = '';
elements.warningsUserSearch.value = '';
elements.warningsUserInput.value = '';
elements.newWarningUserInput.value = '';
elements.warningsList.innerHTML = '';
elements.warningsEmpty.hidden = true;
};
const ensureCommandEntry = (commandName) => {
if (!state.permissions.commandPermissions[commandName]) {
state.permissions.commandPermissions[commandName] = {
allowedRoleIds: [],
allowedUserIds: [],
restricted: false,
};
}
const entry = state.permissions.commandPermissions[commandName];
entry.allowedRoleIds = Array.isArray(entry.allowedRoleIds) ? entry.allowedRoleIds : [];
entry.allowedUserIds = Array.isArray(entry.allowedUserIds) ? entry.allowedUserIds : [];
if (typeof entry.restricted !== 'boolean') {
entry.restricted = entry.allowedRoleIds.length > 0 || entry.allowedUserIds.length > 0;
}
};
const renderCommands = () => {
elements.commandsList.innerHTML = '';
const commands = [...new Set(state.commands)].sort();
if (commands.length === 0) {
elements.commandsEmpty.hidden = false;
return;
}
elements.commandsEmpty.hidden = true;
commands.forEach((commandName) => {
ensureCommandEntry(commandName);
const commandConfig = state.permissions.commandPermissions[commandName];
const isRestricted = Boolean(commandConfig.restricted);
const card = document.createElement('div');
card.className = `command-card${state.botInstalled ? '' : ' command-card--disabled'}`;
const header = document.createElement('div');
header.className = 'command-card__header';
const title = document.createElement('strong');
title.textContent = `/${commandName}`;
const toggleLabel = document.createElement('label');
toggleLabel.className = 'toggle';
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = isRestricted;
toggleLabel.appendChild(toggle);
toggleLabel.appendChild(document.createTextNode('Restricted'));
header.appendChild(title);
header.appendChild(toggleLabel);
const body = document.createElement('div');
body.className = 'command-card__body';
const roleSection = document.createElement('div');
const roleLabel = document.createElement('p');
roleLabel.className = 'muted';
roleLabel.textContent = 'Allowed roles';
const rolePicker = document.createElement('div');
roleSection.appendChild(roleLabel);
roleSection.appendChild(rolePicker);
const userSection = document.createElement('div');
const userLabel = document.createElement('p');
userLabel.className = 'muted';
userLabel.textContent = 'Allowed users';
const userInput = document.createElement('input');
userInput.type = 'text';
userInput.placeholder = 'Paste user IDs';
const addUserButton = document.createElement('button');
addUserButton.type = 'button';
addUserButton.className = 'button';
addUserButton.textContent = 'Add';
const userControls = document.createElement('div');
userControls.className = 'input-row';
userControls.appendChild(userInput);
userControls.appendChild(addUserButton);
const chipList = document.createElement('div');
chipList.className = 'chip-list';
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);
setDirty(true);
renderUserChips();
});
chip.appendChild(remove);
chipList.appendChild(chip);
});
};
const addUsers = () => {
const values = parseIdList(userInput.value);
if (values.length === 0) {
return;
}
const merged = Array.from(new Set([...commandConfig.allowedUserIds, ...values]));
commandConfig.allowedUserIds = merged;
setDirty(true);
userInput.value = '';
renderUserChips();
};
addUserButton.addEventListener('click', addUsers);
userInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
addUsers();
}
});
userSection.appendChild(userLabel);
userSection.appendChild(userControls);
userSection.appendChild(chipList);
renderUserChips();
const actions = document.createElement('div');
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'button button--ghost';
clearButton.textContent = 'Clear';
clearButton.addEventListener('click', () => {
commandConfig.allowedRoleIds = [];
commandConfig.allowedUserIds = [];
commandConfig.restricted = false;
setDirty(true);
renderCommands();
});
actions.appendChild(clearButton);
body.appendChild(roleSection);
body.appendChild(userSection);
body.appendChild(actions);
toggle.addEventListener('change', () => {
setDirty(true);
commandConfig.restricted = toggle.checked;
if (!toggle.checked) {
commandConfig.allowedRoleIds = [];
commandConfig.allowedUserIds = [];
}
renderCommands();
});
const controlsDisabled = !state.botInstalled || !isRestricted;
userInput.disabled = controlsDisabled;
addUserButton.disabled = controlsDisabled;
renderRolePicker(
rolePicker,
state.roles,
commandConfig.allowedRoleIds,
(selected) => {
commandConfig.allowedRoleIds = selected;
setDirty(true);
},
{
disabled: !state.botInstalled || !isRestricted,
emptyMessage: isRestricted ? 'Select roles to allow.' : 'Allow all roles.',
},
);
card.appendChild(header);
card.appendChild(body);
elements.commandsList.appendChild(card);
});
};
const renderPanels = () => {
renderRolePicker(
elements.adminRolesPicker,
state.roles,
state.permissions.adminRoleIds,
(selected) => {
state.permissions.adminRoleIds = selected;
setDirty(true);
},
{
disabled: !state.botInstalled,
emptyMessage: state.botInstalled ? 'Select roles to grant admin access.' : 'Invite bot to load roles.',
},
);
renderAdminUsers();
renderCommands();
if (!state.botInstalled) {
elements.settingsPanel.classList.add('panel--disabled');
elements.permissionsPanel.classList.add('panel--disabled');
elements.botInactiveCallout.hidden = false;
} else {
elements.settingsPanel.classList.remove('panel--disabled');
elements.permissionsPanel.classList.remove('panel--disabled');
elements.botInactiveCallout.hidden = true;
}
};
const updateBotStatusControls = () => {
if (state.botInstalled) {
elements.inviteButton.hidden = true;
elements.refreshBotButton.hidden = true;
} else {
elements.inviteButton.hidden = false;
elements.refreshBotButton.hidden = false;
}
};
const loadSession = async () => {
const data = await fetchDashboardJson('/api/me');
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
dashboardApi.setCsrfToken(data.csrfToken);
if (data.expiresAt) {
const expires = new Date(data.expiresAt);
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
}
};
const loadGuilds = async () => {
setLoadingGuilds(true);
try {
const data = await fetchDashboardJson('/api/guilds');
state.guilds = data.guilds || [];
state.filteredGuilds = [...state.guilds];
if (state.guilds.length > 0 && !state.selectedGuildId) {
state.selectedGuildId = state.guilds[0].id;
}
renderGuilds();
} finally {
setLoadingGuilds(false);
}
};
const loadBotGuilds = async () => {
try {
const data = await fetchDashboardJson('/api/bot/guilds');
state.botGuildIds = new Set(data.guildIds || []);
} catch (error) {
state.botGuildIds = new Set();
handleFetchError(error, 'Failed to fetch bot status.');
}
};
const updateBotInstalled = () => {
state.botInstalled = state.selectedGuildId ? state.botGuildIds.has(state.selectedGuildId) : false;
};
const loadGuildDetails = async () => {
if (!state.selectedGuildId) {
return;
}
setLoadingDetails(true);
setError(null);
setDirty(false);
resetWarningSelection();
const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId);
updateBotInstalled();
updateGuildHeader(guild);
updateBotStatusControls();
try {
const [permissionsData, commandsData] = await Promise.all([
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`),
fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/commands`),
]);
state.permissions = normalizePermissions(permissionsData.permissions || {});
state.permissions.commandPermissions = state.permissions.commandPermissions || {};
state.commands = commandsData.commands || [];
Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
if (!state.commands.includes(commandName)) {
state.commands.push(commandName);
}
});
if (state.botInstalled) {
try {
const rolesData = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/roles`);
state.roles = rolesData.roles || [];
} catch (error) {
if (error?.code === 'BOT_NOT_IN_GUILD') {
state.botInstalled = false;
state.roles = [];
} else {
handleFetchError(error, 'Failed to fetch roles.');
}
}
} else {
state.roles = [];
}
updateGuildHeader(guild);
updateBotStatusControls();
state.originalPermissions = clonePermissions(state.permissions);
renderPanels();
const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'settings';
switchTab(activeTab);
} catch (error) {
handleFetchError(error, 'Failed to load guild data.');
} finally {
setLoadingDetails(false);
}
};
const selectGuild = async (guildId) => {
if (state.dirty) {
const proceed = window.confirm('You have unsaved changes. Discard them and switch guilds?');
if (!proceed) {
return;
}
}
state.selectedGuildId = guildId;
renderGuilds();
await loadGuildDetails();
};
const addAdminUser = () => {
const values = parseIdList(elements.adminUserInput.value);
if (values.length === 0) {
return;
}
state.permissions.adminUserIds = Array.from(new Set([...state.permissions.adminUserIds, ...values]));
elements.adminUserInput.value = '';
setDirty(true);
renderAdminUsers();
};
const addCommand = () => {
const value = elements.newCommandInput.value.trim();
if (!value) {
return;
}
if (!COMMAND_NAME_REGEX.test(value)) {
setError({message: 'Command name must be 1-32 characters and use letters, numbers, - or _.'});
return;
}
if (!state.commands.includes(value)) {
state.commands.push(value);
setDirty(true);
}
elements.newCommandInput.value = '';
renderCommands();
};
const savePermissions = async () => {
if (!state.selectedGuildId) {
return;
}
setLoadingDetails(true);
try {
const data = await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/permissions`, {
method: 'PATCH',
body: JSON.stringify(state.permissions),
});
state.permissions = normalizePermissions(data.permissions || {});
state.originalPermissions = clonePermissions(state.permissions);
setDirty(false);
dashboardApi.showToast('Permissions saved.', 'success');
renderPanels();
} catch (error) {
handleFetchError(error, 'Failed to save permissions.');
} finally {
setLoadingDetails(false);
}
};
const discardChanges = () => {
if (!state.originalPermissions) {
return;
}
state.permissions = clonePermissions(state.originalPermissions);
setDirty(false);
renderPanels();
};
const switchTab = (tabName) => {
elements.tabs.forEach((tab) => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
elements.settingsPanel.hidden = tabName !== 'settings';
elements.permissionsPanel.hidden = tabName !== 'permissions';
elements.warningsPanel.hidden = tabName !== 'warnings';
};
const resolveWarningUserId = (value) => {
if (value && value.trim()) {
return value.trim();
}
return state.selectedWarningUser?.id || '';
};
const renderWarningMembers = (members = []) => {
elements.warningsUserResults.innerHTML = '';
if (!members.length) {
const empty = document.createElement('span');
empty.className = 'muted';
empty.textContent = 'No members found.';
elements.warningsUserResults.appendChild(empty);
return;
}
members.forEach((member) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `member-item${state.selectedWarningUser?.id === member.id ? ' active' : ''}`;
const meta = document.createElement('div');
meta.className = 'member-item__meta';
const name = document.createElement('span');
name.className = 'member-item__name';
name.textContent = formatMemberDisplay(member);
const id = document.createElement('span');
id.className = 'member-item__id';
id.textContent = member.id;
meta.appendChild(name);
meta.appendChild(id);
button.appendChild(meta);
button.addEventListener('click', () => {
setSelectedWarningUser(member);
renderWarningMembers(members);
});
elements.warningsUserResults.appendChild(button);
});
};
const searchWarningMembers = async (query) => {
if (!state.selectedGuildId || !query || query.trim().length < 2) {
elements.warningsUserResults.innerHTML = '';
return;
}
try {
const data = await fetchDashboardJson(
`/api/guilds/${state.selectedGuildId}/members?query=${encodeURIComponent(query.trim())}`,
);
const members = data.members || [];
renderWarningMembers(members);
} catch (error) {
handleFetchError(error, 'Failed to search members.');
}
};
const fetchWarnings = async () => {
const userId = resolveWarningUserId(elements.warningsUserInput.value);
if (!userId) {
setError({message: 'Enter a user ID to fetch warnings.'});
return;
}
elements.warningsSpinner.hidden = false;
try {
const data = await fetchDashboardJson(`/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');
item.className = 'warning-item';
const text = document.createElement('p');
text.textContent = warning.reason;
const meta = document.createElement('div');
meta.className = 'warning-item__meta';
const dateText = warning.createdAt ? new Date(warning.createdAt).toLocaleString() : 'Unknown date';
const moderator = document.createElement('span');
moderator.textContent = `Moderator: ${warning.moderatorId || 'Unknown'}`;
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.className = 'button button--ghost';
copyButton.textContent = 'Copy moderator ID';
copyButton.addEventListener('click', async () => {
if (!warning.moderatorId) {
return;
}
try {
await navigator.clipboard.writeText(warning.moderatorId);
dashboardApi.showToast('Moderator ID copied.', 'success');
} catch (error) {
dashboardApi.showToast('Unable to copy moderator ID.', 'error');
}
});
meta.appendChild(moderator);
meta.appendChild(document.createElement('span')).textContent = dateText;
item.appendChild(text);
item.appendChild(meta);
item.appendChild(copyButton);
elements.warningsList.appendChild(item);
});
}
setError(null);
} catch (error) {
handleFetchError(error, 'Failed to fetch warnings.');
} finally {
elements.warningsSpinner.hidden = true;
}
};
const updateWarningCount = () => {
const length = elements.warningReasonInput.value.length;
elements.warningCharCount.textContent = `${length}/200`;
};
const addWarning = async () => {
const userId = resolveWarningUserId(elements.newWarningUserInput.value);
const reason = elements.warningReasonInput.value.trim();
if (!userId || !reason) {
elements.warningStatus.textContent = 'User ID and reason are required.';
return;
}
if (reason.length > 200) {
elements.warningStatus.textContent = 'Reason must be under 200 characters.';
return;
}
elements.warningStatus.textContent = '';
try {
const data = await fetchDashboardJson(`/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 = '';
updateWarningCount();
dashboardApi.showToast('Warning saved.', 'success');
} catch (error) {
handleFetchError(error, 'Failed to add warning.');
}
};
const clearWarnings = async () => {
const userId = resolveWarningUserId(elements.warningsUserInput.value);
if (!userId) {
setError({message: 'Select a user to clear warnings.'});
return;
}
const proceed = window.confirm('Clear all warnings for this user?');
if (!proceed) {
return;
}
try {
await fetchDashboardJson(`/api/guilds/${state.selectedGuildId}/warnings/${userId}`, {
method: 'DELETE',
});
elements.warningsList.innerHTML = '';
elements.warningsEmpty.hidden = false;
elements.warningStatus.textContent = 'Warnings cleared.';
dashboardApi.showToast('Warnings cleared.', 'success');
} catch (error) {
handleFetchError(error, 'Failed to clear warnings.');
}
};
const refreshBotStatus = async () => {
await loadBotGuilds();
updateBotInstalled();
const guild = state.guilds.find((entry) => entry.id === state.selectedGuildId);
updateGuildHeader(guild);
updateBotStatusControls();
renderPanels();
};
const openInvite = async () => {
try {
const data = await fetchDashboardJson(`/api/invite-url?guildId=${state.selectedGuildId}`);
if (data.inviteUrl) {
window.open(data.inviteUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
handleFetchError(error, 'Failed to generate invite URL.');
}
};
const openCopyModal = () => {
elements.copyGuildSelect.innerHTML = '';
state.guilds
.filter((guild) => guild.id !== state.selectedGuildId)
.forEach((guild) => {
const option = document.createElement('option');
option.value = guild.id;
option.textContent = guild.name;
elements.copyGuildSelect.appendChild(option);
});
elements.copyModal.hidden = false;
};
const closeCopyModal = () => {
elements.copyModal.hidden = true;
};
const confirmCopy = async () => {
const guildId = elements.copyGuildSelect.value;
if (!guildId) {
closeCopyModal();
return;
}
try {
const data = await fetchDashboardJson(`/api/guilds/${guildId}/permissions`);
state.permissions = normalizePermissions(data.permissions || {});
setDirty(true);
renderPanels();
dashboardApi.showToast('Permissions copied. Review before saving.', 'success');
} catch (error) {
handleFetchError(error, 'Failed to copy permissions.');
} finally {
closeCopyModal();
}
};
const initDashboard = async () => {
try {
await loadSession();
await Promise.all([loadGuilds(), loadBotGuilds()]);
renderGuilds();
if (state.selectedGuildId) {
await loadGuildDetails();
}
} catch (error) {
setError({message: 'Please log in to access the dashboard.'});
window.location.href = '/';
return;
}
elements.logoutButton.addEventListener('click', async () => {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
...(dashboardApi.getCsrfToken() ? {'X-CSRF-Token': dashboardApi.getCsrfToken()} : {}),
},
});
window.location.href = '/';
});
elements.tabs.forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
elements.addAdminUserButton.addEventListener('click', addAdminUser);
elements.adminUserInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
addAdminUser();
}
});
elements.addCommandButton.addEventListener('click', addCommand);
elements.savePermissionsButton.addEventListener('click', savePermissions);
elements.discardChangesButton.addEventListener('click', discardChanges);
elements.fetchWarningsButton.addEventListener('click', fetchWarnings);
elements.clearWarningsButton.addEventListener('click', clearWarnings);
elements.addWarningButton.addEventListener('click', addWarning);
elements.warningReasonInput.addEventListener('input', updateWarningCount);
elements.warningsUserSearch.addEventListener(
'input',
dashboardApi.debounce((event) => {
searchWarningMembers(event.target.value);
}, 250),
);
elements.warningsUserInput.addEventListener('input', (event) => {
setManualWarningUser(event.target.value.trim());
});
elements.newWarningUserInput.addEventListener('input', (event) => {
if (event.target.value.trim()) {
setManualWarningUser(event.target.value.trim());
}
});
elements.inviteButton.addEventListener('click', openInvite);
elements.refreshBotButton.addEventListener('click', refreshBotStatus);
elements.clearAllCommands.addEventListener('click', () => {
Object.keys(state.permissions.commandPermissions).forEach((commandName) => {
state.permissions.commandPermissions[commandName] = {
allowedRoleIds: [],
allowedUserIds: [],
restricted: false,
};
});
setDirty(true);
renderCommands();
});
elements.copyFromGuild.addEventListener('click', openCopyModal);
elements.cancelCopyButton.addEventListener('click', closeCopyModal);
elements.confirmCopyButton.addEventListener('click', confirmCopy);
updateWarningCount();
elements.guildSearch.addEventListener(
'input',
dashboardApi.debounce((event) => {
const query = event.target.value.toLowerCase();
state.filteredGuilds = state.guilds.filter((guild) => guild.name.toLowerCase().includes(query));
renderGuilds();
}, 200),
);
window.addEventListener('beforeunload', (event) => {
if (state.dirty) {
event.preventDefault();
event.returnValue = '';
}
});
};
document.addEventListener('DOMContentLoaded', () => {
initDashboard();
});