feat(arbiter): Add Discord dashboard to Trinity Console
- New sidebar entry for Discord - Full server structure visualization - Channel tree with expandable categories - Role hierarchy with color badges - Health checks (orphan channels, empty roles, bot roles) - Search/filter across channels and roles - Click channel to see permission overwrites - Click role to see explicit channel access - Responsive design with modal details view Chronicler: #70
This commit is contained in:
committed by
Claude
parent
04bc2e734f
commit
b96ab1fb24
@@ -9,6 +9,124 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /admin/discord
|
||||
* Main Discord audit dashboard
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const client = req.app.locals.client;
|
||||
const guildId = process.env.GUILD_ID;
|
||||
|
||||
if (!client || !client.isReady()) {
|
||||
return res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: 'Discord client not ready',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: 'Guild not found',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Build channel structure
|
||||
const channels = guild.channels.cache.map(ch => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
typeName: getChannelTypeName(ch.type),
|
||||
parentId: ch.parentId,
|
||||
position: ch.position,
|
||||
nsfw: ch.nsfw || false,
|
||||
topic: ch.topic || null,
|
||||
permissionOverwrites: ch.permissionOverwrites?.cache.map(p => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
allow: p.allow.bitfield.toString(),
|
||||
deny: p.deny.bitfield.toString()
|
||||
})) || []
|
||||
})).sort((a, b) => a.position - b.position);
|
||||
|
||||
// Build role structure with permission overwrites lookup
|
||||
const roles = guild.roles.cache.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.hexColor,
|
||||
position: r.position,
|
||||
permissions: r.permissions.bitfield.toString(),
|
||||
mentionable: r.mentionable,
|
||||
managed: r.managed,
|
||||
memberCount: r.members.size
|
||||
})).sort((a, b) => b.position - a.position);
|
||||
|
||||
// Categories with their children
|
||||
const categories = channels
|
||||
.filter(ch => ch.type === 4)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: channels.filter(ch => ch.parentId === cat.id)
|
||||
}));
|
||||
|
||||
// Orphan channels
|
||||
const orphanChannels = channels.filter(ch => !ch.parentId && ch.type !== 4);
|
||||
|
||||
// Server info
|
||||
const serverInfo = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
memberCount: guild.memberCount,
|
||||
ownerId: guild.ownerId,
|
||||
createdAt: guild.createdAt,
|
||||
icon: guild.iconURL(),
|
||||
features: guild.features
|
||||
};
|
||||
|
||||
// Health checks
|
||||
const healthChecks = {
|
||||
orphanChannels: orphanChannels.length,
|
||||
emptyRoles: roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone').length,
|
||||
botRoles: roles.filter(r => r.managed).length
|
||||
};
|
||||
|
||||
res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: null,
|
||||
data: {
|
||||
server: serverInfo,
|
||||
categories,
|
||||
orphanChannels,
|
||||
allChannels: channels,
|
||||
roles,
|
||||
healthChecks,
|
||||
summary: {
|
||||
totalChannels: channels.length,
|
||||
totalRoles: roles.length,
|
||||
categoryCount: categories.length,
|
||||
orphanCount: orphanChannels.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Discord dashboard error:', error);
|
||||
res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: error.message,
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/discord/audit
|
||||
* Full Discord server audit - channels, roles, members
|
||||
|
||||
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
@@ -0,0 +1,467 @@
|
||||
<% if (error) { %>
|
||||
<div class="bg-red-500/10 border border-red-500/50 rounded-lg p-6 text-center">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<div class="text-red-400 font-medium"><%= error %></div>
|
||||
</div>
|
||||
<% } else if (data) { %>
|
||||
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex-1 min-w-64">
|
||||
<input type="text" id="search-input" placeholder="Search channels or roles..."
|
||||
class="w-full px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-frost">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="setView('channels')" id="btn-channels" class="px-4 py-2 rounded-lg bg-frost text-white font-medium transition">
|
||||
💬 Channels
|
||||
</button>
|
||||
<button onclick="setView('roles')" id="btn-roles" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
|
||||
🎭 Roles
|
||||
</button>
|
||||
<button onclick="setView('health')" id="btn-health" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
|
||||
🩺 Health
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-frost"><%= data.summary.totalChannels %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Channels</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-fire"><%= data.summary.totalRoles %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Roles</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-universal"><%= data.summary.categoryCount %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Categories</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold"><%= data.server.memberCount %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<% if (data.healthChecks.orphanChannels === 0) { %>
|
||||
<div class="text-2xl font-bold text-green-500">✓</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Healthy</div>
|
||||
<% } else { %>
|
||||
<div class="text-2xl font-bold text-yellow-500"><%= data.healthChecks.orphanChannels %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Orphans</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Channels Panel (left 2/3) -->
|
||||
<div id="panel-channels" class="lg:col-span-2">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<img src="<%= data.server.icon %>" class="w-8 h-8 rounded-full" alt="">
|
||||
<h3 class="font-semibold"><%= data.server.name %></h3>
|
||||
</div>
|
||||
<div class="p-4 max-h-[600px] overflow-y-auto" id="channel-tree">
|
||||
<% data.categories.forEach((cat, catIndex) => { %>
|
||||
<div class="channel-item category-item mb-2" data-name="<%= cat.name.toLowerCase() %>">
|
||||
<div class="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" onclick="toggleCategory('<%= cat.id %>')">
|
||||
<span class="text-gray-400 transition-transform" id="arrow-<%= cat.id %>">▶</span>
|
||||
<span class="text-gray-400">📁</span>
|
||||
<span class="font-medium text-sm uppercase text-gray-600 dark:text-gray-300"><%= cat.name %></span>
|
||||
<span class="text-xs text-gray-400 ml-auto"><%= cat.children.length %></span>
|
||||
</div>
|
||||
<div id="cat-<%= cat.id %>" class="hidden ml-6 mt-1 space-y-1">
|
||||
<% cat.children.forEach(ch => { %>
|
||||
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
data-name="<%= ch.name.toLowerCase() %>"
|
||||
onclick="showChannelDetails('<%= ch.id %>')">
|
||||
<% if (ch.type === 0) { %>
|
||||
<span class="text-gray-400">#</span>
|
||||
<% } else if (ch.type === 2) { %>
|
||||
<span class="text-gray-400">🔊</span>
|
||||
<% } else if (ch.type === 5) { %>
|
||||
<span class="text-gray-400">📢</span>
|
||||
<% } else if (ch.type === 13) { %>
|
||||
<span class="text-gray-400">🎭</span>
|
||||
<% } else if (ch.type === 15) { %>
|
||||
<span class="text-gray-400">💬</span>
|
||||
<% } else { %>
|
||||
<span class="text-gray-400">📄</span>
|
||||
<% } %>
|
||||
<span class="text-sm"><%= ch.name %></span>
|
||||
<% if (ch.nsfw) { %>
|
||||
<span class="text-xs bg-red-500/20 text-red-400 px-1 rounded">NSFW</span>
|
||||
<% } %>
|
||||
<% if (ch.permissionOverwrites.length > 0) { %>
|
||||
<span class="text-xs text-gray-400 ml-auto">🔒 <%= ch.permissionOverwrites.length %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
|
||||
<% if (data.orphanChannels.length > 0) { %>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xs text-yellow-500 font-medium mb-2">⚠️ Orphan Channels (no category)</div>
|
||||
<% data.orphanChannels.forEach(ch => { %>
|
||||
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
data-name="<%= ch.name.toLowerCase() %>"
|
||||
onclick="showChannelDetails('<%= ch.id %>')">
|
||||
<span class="text-yellow-500">#</span>
|
||||
<span class="text-sm"><%= ch.name %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Panel (right 1/3) -->
|
||||
<div id="panel-roles" class="hidden lg:block lg:col-span-1">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold">🎭 Role Hierarchy</h3>
|
||||
</div>
|
||||
<div class="p-4 max-h-[600px] overflow-y-auto space-y-1" id="role-list">
|
||||
<% data.roles.forEach(role => { %>
|
||||
<div class="role-item flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
data-name="<%= role.name.toLowerCase() %>"
|
||||
data-role-id="<%= role.id %>"
|
||||
onclick="showRoleDetails('<%= role.id %>')">
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
|
||||
<span class="text-sm truncate flex-1"><%= role.name %></span>
|
||||
<% if (role.managed) { %>
|
||||
<span class="text-xs bg-blue-500/20 text-blue-400 px-1 rounded">BOT</span>
|
||||
<% } %>
|
||||
<span class="text-xs text-gray-400"><%= role.memberCount %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Panel (hidden by default) -->
|
||||
<div id="panel-health" class="hidden lg:col-span-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Orphan Channels -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||
<% if (data.healthChecks.orphanChannels === 0) { %>
|
||||
<span class="text-green-500">✓</span>
|
||||
<% } else { %>
|
||||
<span class="text-yellow-500">⚠️</span>
|
||||
<% } %>
|
||||
Orphan Channels
|
||||
</h4>
|
||||
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.orphanChannels === 0 ? 'text-green-500' : 'text-yellow-500' %>">
|
||||
<%= data.healthChecks.orphanChannels %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Channels without a parent category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty Roles -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||
<% if (data.healthChecks.emptyRoles <= 5) { %>
|
||||
<span class="text-green-500">✓</span>
|
||||
<% } else { %>
|
||||
<span class="text-yellow-500">⚠️</span>
|
||||
<% } %>
|
||||
Empty Roles
|
||||
</h4>
|
||||
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.emptyRoles <= 5 ? 'text-green-500' : 'text-yellow-500' %>">
|
||||
<%= data.healthChecks.emptyRoles %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Non-bot roles with no members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bot Roles -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||
<span class="text-blue-500">🤖</span>
|
||||
Bot Roles
|
||||
</h4>
|
||||
<div class="text-3xl font-bold mb-2 text-blue-500">
|
||||
<%= data.healthChecks.botRoles %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Managed by Discord integrations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Roles List -->
|
||||
<% const emptyRoles = data.roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone'); %>
|
||||
<% if (emptyRoles.length > 0) { %>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mt-6">
|
||||
<h4 class="font-medium mb-3">Empty Roles (candidates for cleanup)</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% emptyRoles.forEach(role => { %>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
|
||||
style="background-color: <%= role.color === '#000000' ? '#374151' : role.color %>20; border: 1px solid <%= role.color === '#000000' ? '#374151' : role.color %>">
|
||||
<span class="w-2 h-2 rounded-full" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
|
||||
<%= role.name %>
|
||||
</span>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div id="details-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50" onclick="closeModal(event)">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[80vh] overflow-hidden" onclick="event.stopPropagation()">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="font-semibold" id="modal-title">Details</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl">×</button>
|
||||
</div>
|
||||
<div class="p-4 overflow-y-auto max-h-[60vh]" id="modal-content">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Store data for JS access
|
||||
const discordData = <%- JSON.stringify(data) %>;
|
||||
|
||||
// Role lookup by ID
|
||||
const rolesById = {};
|
||||
discordData.roles.forEach(r => rolesById[r.id] = r);
|
||||
|
||||
// Channel lookup by ID
|
||||
const channelsById = {};
|
||||
discordData.allChannels.forEach(ch => channelsById[ch.id] = ch);
|
||||
|
||||
// View switching
|
||||
function setView(view) {
|
||||
// Update buttons
|
||||
document.getElementById('btn-channels').className = view === 'channels'
|
||||
? 'px-4 py-2 rounded-lg bg-frost text-white font-medium transition'
|
||||
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||
document.getElementById('btn-roles').className = view === 'roles'
|
||||
? 'px-4 py-2 rounded-lg bg-fire text-white font-medium transition'
|
||||
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||
document.getElementById('btn-health').className = view === 'health'
|
||||
? 'px-4 py-2 rounded-lg bg-universal text-white font-medium transition'
|
||||
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||
|
||||
// Show/hide panels
|
||||
document.getElementById('panel-channels').className = view === 'channels' ? 'lg:col-span-2' : 'hidden';
|
||||
document.getElementById('panel-roles').className = view === 'roles' || view === 'channels' ? 'lg:col-span-1' : 'hidden';
|
||||
document.getElementById('panel-health').className = view === 'health' ? 'lg:col-span-3' : 'hidden';
|
||||
|
||||
// Adjust for roles-only view
|
||||
if (view === 'roles') {
|
||||
document.getElementById('panel-roles').className = 'lg:col-span-3';
|
||||
}
|
||||
}
|
||||
|
||||
// Category toggle
|
||||
function toggleCategory(catId) {
|
||||
const content = document.getElementById('cat-' + catId);
|
||||
const arrow = document.getElementById('arrow-' + catId);
|
||||
if (content.classList.contains('hidden')) {
|
||||
content.classList.remove('hidden');
|
||||
arrow.style.transform = 'rotate(90deg)';
|
||||
} else {
|
||||
content.classList.add('hidden');
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Show channel details
|
||||
function showChannelDetails(channelId) {
|
||||
const channel = channelsById[channelId];
|
||||
if (!channel) return;
|
||||
|
||||
let html = `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Channel</div>
|
||||
<div class="font-medium"># ${channel.name}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
|
||||
<div>${channel.typeName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
|
||||
<div class="text-xs font-mono">${channel.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
${channel.topic ? `<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Topic</div>
|
||||
<div class="text-sm">${channel.topic}</div>
|
||||
</div>` : ''}
|
||||
`;
|
||||
|
||||
if (channel.permissionOverwrites.length > 0) {
|
||||
html += `<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Permission Overwrites</div>
|
||||
<div class="space-y-1">`;
|
||||
|
||||
channel.permissionOverwrites.forEach(p => {
|
||||
const role = rolesById[p.id];
|
||||
const name = role ? role.name : `User ${p.id.slice(-4)}`;
|
||||
const color = role ? (role.color === '#000000' ? '#6b7280' : role.color) : '#6b7280';
|
||||
const allow = p.allow !== '0' ? '✓ Allow' : '';
|
||||
const deny = p.deny !== '0' ? '✗ Deny' : '';
|
||||
|
||||
html += `<div class="flex items-center gap-2 text-sm px-2 py-1 rounded bg-gray-100 dark:bg-gray-800">
|
||||
<span class="w-2 h-2 rounded-full" style="background-color: ${color}"></span>
|
||||
<span class="flex-1">${name}</span>
|
||||
${allow ? `<span class="text-green-500 text-xs">${allow}</span>` : ''}
|
||||
${deny ? `<span class="text-red-500 text-xs">${deny}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('modal-title').textContent = '# ' + channel.name;
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
document.getElementById('details-modal').classList.remove('hidden');
|
||||
document.getElementById('details-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
// Show role details
|
||||
function showRoleDetails(roleId) {
|
||||
const role = rolesById[roleId];
|
||||
if (!role) return;
|
||||
|
||||
// Find channels this role can access
|
||||
const accessibleChannels = discordData.allChannels.filter(ch => {
|
||||
// Check if role has explicit permission
|
||||
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
|
||||
if (overwrite) {
|
||||
return overwrite.allow !== '0' || overwrite.deny === '0';
|
||||
}
|
||||
// Check if @everyone is denied (and role doesn't have explicit access)
|
||||
const everyoneOverwrite = ch.permissionOverwrites.find(p => p.id === discordData.server.id);
|
||||
if (everyoneOverwrite && everyoneOverwrite.deny !== '0') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const color = role.color === '#000000' ? '#6b7280' : role.color;
|
||||
|
||||
let html = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-6 h-6 rounded-full" style="background-color: ${color}"></span>
|
||||
<div>
|
||||
<div class="font-medium">${role.name}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Position: ${role.position}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Members</div>
|
||||
<div class="text-xl font-bold">${role.memberCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
|
||||
<div>${role.managed ? '🤖 Bot Managed' : '👥 Regular'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
|
||||
<div class="text-xs font-mono">${role.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Explicit Channel Access (${accessibleChannels.filter(ch => ch.permissionOverwrites.some(p => p.id === roleId)).length})</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
`;
|
||||
|
||||
// Show channels with explicit overwrites for this role
|
||||
const explicitChannels = discordData.allChannels.filter(ch =>
|
||||
ch.permissionOverwrites.some(p => p.id === roleId)
|
||||
);
|
||||
|
||||
if (explicitChannels.length === 0) {
|
||||
html += '<span class="text-gray-500 text-sm">No explicit overwrites</span>';
|
||||
} else {
|
||||
explicitChannels.forEach(ch => {
|
||||
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
|
||||
const isAllowed = overwrite && overwrite.allow !== '0';
|
||||
const isDenied = overwrite && overwrite.deny !== '0';
|
||||
|
||||
html += `<span class="text-xs px-2 py-1 rounded ${isAllowed ? 'bg-green-500/20 text-green-400' : isDenied ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'}">
|
||||
# ${ch.name}
|
||||
</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `</div></div></div>`;
|
||||
|
||||
document.getElementById('modal-title').innerHTML = `<span class="inline-block w-3 h-3 rounded-full mr-2" style="background-color: ${color}"></span>${role.name}`;
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
document.getElementById('details-modal').classList.remove('hidden');
|
||||
document.getElementById('details-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal(event) {
|
||||
if (!event || event.target === document.getElementById('details-modal')) {
|
||||
document.getElementById('details-modal').classList.add('hidden');
|
||||
document.getElementById('details-modal').classList.remove('flex');
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||
const query = e.target.value.toLowerCase();
|
||||
|
||||
// Filter channels
|
||||
document.querySelectorAll('.channel-item').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
el.style.display = name.includes(query) ? '' : 'none';
|
||||
});
|
||||
|
||||
// Filter roles
|
||||
document.querySelectorAll('.role-item').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
el.style.display = name.includes(query) ? '' : 'none';
|
||||
});
|
||||
|
||||
// If searching, expand all categories
|
||||
if (query) {
|
||||
document.querySelectorAll('[id^="cat-"]').forEach(el => {
|
||||
el.classList.remove('hidden');
|
||||
});
|
||||
document.querySelectorAll('[id^="arrow-"]').forEach(el => {
|
||||
el.style.transform = 'rotate(90deg)';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcut to close modal
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Expand all categories on load for better UX
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Keep categories collapsed by default - user can expand as needed
|
||||
});
|
||||
</script>
|
||||
|
||||
<% } %>
|
||||
@@ -95,6 +95,9 @@
|
||||
<a href="/admin/scheduler" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/scheduler') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
⏰ Restart Scheduler
|
||||
</a>
|
||||
<a href="/admin/discord" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
💬 Discord
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user