1077 lines
39 KiB
JavaScript
1077 lines
39 KiB
JavaScript
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();
|
||
});
|