EveCogs/dashboard/web/app.js
EveCogs 20d6bd2c82 feat: EveDash v1.0.0 — Self-contained web dashboard for Red-DiscordBot
- Embedded aiohttp server (no external packages needed beyond aiohttp)
- Discord-inspired dark theme SPA frontend
- Discord OAuth2 authentication with JWT sessions
- Full REST API: bot info, guild management, settings, commands, cogs
- Real-time WebSocket for live events
- Third-party cog SDK: @dashboard_page decorator for easy integration
- Guild selector with search, command toggles, cog load/unload/reload
- Admin panel: global config, blacklist management
- Responsive design, toast notifications, smooth animations
- 3,604 lines across 8 files
2026-05-07 23:47:55 +00:00

1116 lines
53 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════════════
EveDash — Single-Page Application
═══════════════════════════════════════════════════════════════════════ */
(() => {
"use strict";
// ── State ──────────────────────────────────────────────────────────────
const state = {
user: null,
token: null,
botInfo: null,
guilds: [],
selectedGuild: null,
sidebarCollapsed: false,
ws: null,
wsRetries: 0,
};
// ── API Client ─────────────────────────────────────────────────────────
const api = {
async fetch(path, opts = {}) {
const headers = { "Content-Type": "application/json", ...opts.headers };
if (state.token) headers["Authorization"] = `Bearer ${state.token}`;
const res = await fetch(`/api${path}`, { ...opts, headers });
if (res.status === 401) { logout(); return null; }
const data = await res.json();
if (data.error && res.status >= 400) { toast(data.error, "error"); return null; }
return data;
},
get: (p) => api.fetch(p),
post: (p, body) => api.fetch(p, { method: "POST", body: JSON.stringify(body || {}) }),
put: (p, body) => api.fetch(p, { method: "PUT", body: JSON.stringify(body || {}) }),
del: (p) => api.fetch(p, { method: "DELETE" }),
};
// ── Toast system ───────────────────────────────────────────────────────
function toast(msg, type = "info") {
let container = document.querySelector(".toast-container");
if (!container) { container = document.createElement("div"); container.className = "toast-container"; document.body.appendChild(container); }
const icons = { success: "fa-check-circle", error: "fa-exclamation-circle", warning: "fa-exclamation-triangle", info: "fa-info-circle" };
const el = document.createElement("div");
el.className = `toast ${type}`;
el.innerHTML = `<i class="fas ${icons[type] || icons.info}"></i><span>${msg}</span><button class="toast-close" onclick="this.parentElement.classList.add('removing');setTimeout(()=>this.parentElement.remove(),300)"><i class="fas fa-times"></i></button>`;
container.appendChild(el);
setTimeout(() => { el.classList.add("removing"); setTimeout(() => el.remove(), 300); }, 4000);
}
// ── WebSocket ──────────────────────────────────────────────────────────
function connectWS() {
if (state.ws && state.ws.readyState <= 1) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
ws.send(JSON.stringify({ token: state.token }));
state.wsRetries = 0;
};
ws.onmessage = (e) => {
try {
const { event, data } = JSON.parse(e.data);
if (event === "connected") return;
if (event === "pong") return;
if (["member_join", "member_remove"].includes(event) && state.selectedGuild) {
// Could refresh guild data here
}
} catch {}
};
ws.onclose = () => {
state.ws = null;
if (state.user && state.wsRetries < 5) {
state.wsRetries++;
setTimeout(connectWS, 2000 * state.wsRetries);
}
};
state.ws = ws;
// Keepalive ping
setInterval(() => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 30000);
}
// ── Router ─────────────────────────────────────────────────────────────
function navigate(path) {
location.hash = path;
}
function getRoute() {
return location.hash.slice(1) || "/";
}
function parseQuery(hash) {
const [, qs] = hash.split("?");
if (!qs) return {};
return Object.fromEntries(new URLSearchParams(qs));
}
// ── Auth ───────────────────────────────────────────────────────────────
async function login() {
const data = await api.get("/auth/login");
if (data && data.url) window.location.href = data.url;
}
function logout() {
state.user = null;
state.token = null;
localStorage.removeItem("eve_token");
navigate("/login");
}
async function checkAuth() {
state.token = localStorage.getItem("eve_token");
if (!state.token) return false;
const data = await api.get("/auth/me");
if (!data) { state.token = null; localStorage.removeItem("eve_token"); return false; }
state.user = data;
return true;
}
// ── Rendering helpers ──────────────────────────────────────────────────
function $(sel, el = document) { return el.querySelector(sel); }
function $$(sel, el = document) { return el.querySelectorAll(sel); }
function html(strings, ...vals) {
return strings.reduce((acc, str, i) => acc + str + (vals[i] ?? ""), "");
}
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
function guildIcon(guild, size = 32) {
if (guild.icon) return `<img class="guild-icon" src="${guild.icon}" width="${size}" height="${size}" style="border-radius:var(--radius-sm);object-fit:cover;">`;
return `<div class="guild-icon-placeholder" style="width:${size}px;height:${size}px;font-size:${size * 0.4}px">${guild.name.charAt(0).toUpperCase()}</div>`;
}
// ── Page: Login ────────────────────────────────────────────────────────
function renderLogin() {
return html`
<div class="login-page">
<div class="login-bg"></div>
<div class="login-card">
<div class="logo"><i class="fas fa-robot"></i></div>
<h1>Eve<span>Dash</span></h1>
<p>Manage your Red-DiscordBot from a sleek web interface</p>
<button class="login-btn" onclick="window.__login()">
<i class="fab fa-discord"></i> Login with Discord
</button>
<div class="login-features">
<div class="login-feature"><i class="fas fa-check"></i> Guild Management</div>
<div class="login-feature"><i class="fas fa-check"></i> Cog Control</div>
<div class="login-feature"><i class="fas fa-check"></i> Command Toggles</div>
<div class="login-feature"><i class="fas fa-check"></i> Real-time Updates</div>
<div class="login-feature"><i class="fas fa-check"></i> Third-party Settings</div>
<div class="login-feature"><i class="fas fa-check"></i> Bot Owner Admin</div>
</div>
</div>
</div>`;
}
// ── Sidebar ────────────────────────────────────────────────────────────
function renderSidebar() {
const u = state.user;
const g = state.selectedGuild;
const isOwner = u && u.is_owner;
const route = getRoute();
const guildNav = g ? html`
<div class="nav-section-title">Server</div>
<div class="nav-item ${route.includes('/guild/') && route.includes('/overview') ? 'active' : ''}" data-nav="/guild/${g.id}/overview">
<i class="fas fa-chart-bar"></i><span class="nav-text">Overview</span>
</div>
<div class="nav-item ${route.includes('/settings') ? 'active' : ''}" data-nav="/guild/${g.id}/settings">
<i class="fas fa-cog"></i><span class="nav-text">Settings</span>
</div>
<div class="nav-item ${route.includes('/commands') ? 'active' : ''}" data-nav="/guild/${g.id}/commands">
<i class="fas fa-terminal"></i><span class="nav-text">Commands</span>
</div>
<div class="nav-item ${route.includes('/third-parties') ? 'active' : ''}" data-nav="/guild/${g.id}/third-parties">
<i class="fas fa-puzzle-piece"></i><span class="nav-text">Third Parties</span>
</div>
` : "";
const adminNav = isOwner ? html`
<div class="nav-section-title">Admin</div>
<div class="nav-item ${route === '/cogs' ? 'active' : ''}" data-nav="/cogs">
<i class="fas fa-cubes"></i><span class="nav-text">Cog Management</span>
</div>
<div class="nav-item ${route === '/admin' ? 'active' : ''}" data-nav="/admin">
<i class="fas fa-shield-alt"></i><span class="nav-text">Admin Panel</span>
</div>
` : "";
return html`
<aside class="sidebar ${state.sidebarCollapsed ? 'collapsed' : ''}" id="sidebar">
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas ${state.sidebarCollapsed ? 'fa-chevron-right' : 'fa-chevron-left'}"></i>
</button>
<div class="sidebar-brand">
<div class="brand-icon"><i class="fas fa-robot"></i></div>
<div class="brand-text">Eve<span>Dash</span></div>
</div>
<div class="guild-selector" id="guild-selector">
${g
? html`${guildIcon(g)}
<div class="guild-selector-text">
<div class="guild-name">${g.name}</div>
<div class="guild-members">${formatNumber(g.member_count)} members</div>
</div>
<i class="fas fa-chevron-down chevron"></i>`
: html`<div class="guild-icon-placeholder" style="width:32px;height:32px;font-size:0.85rem"><i class="fas fa-plus"></i></div>
<div class="guild-selector-text">
<div class="guild-name">Select a Server</div>
<div class="guild-members">Choose a server to manage</div>
</div>
<i class="fas fa-chevron-down chevron"></i>`
}
<div class="guild-dropdown" id="guild-dropdown"></div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-title">General</div>
<div class="nav-item ${route === '/' || route === '/home' ? 'active' : ''}" data-nav="/home">
<i class="fas fa-home"></i><span class="nav-text">Home</span>
</div>
<div class="nav-item ${route === '/guilds' ? 'active' : ''}" data-nav="/guilds">
<i class="fas fa-server"></i><span class="nav-text">Servers</span>
<span class="nav-badge">${state.guilds.length}</span>
</div>
${guildNav}
${adminNav}
</nav>
<div class="sidebar-footer">
${u ? html`
<img class="user-avatar" src="${u.avatar || ''}" alt="">
<div class="sidebar-footer-info">
<div class="user-name">${u.display_name || u.username}</div>
<div class="user-role">${u.is_owner ? '👑 Bot Owner' : 'Admin'}</div>
</div>
<button class="logout-btn" title="Logout" onclick="window.__logout()">
<i class="fas fa-sign-out-alt"></i>
</button>
` : ""}
</div>
</aside>`;
}
// ── Page: Home ─────────────────────────────────────────────────────────
async function renderHome() {
const info = state.botInfo || await api.get("/bot/info");
if (info) state.botInfo = info;
const stats = await api.get("/bot/stats");
if (!info) return `<div class="page-body"><div class="empty-state"><i class="fas fa-exclamation-triangle"></i><h3>Error</h3><p>Could not load bot info.</p></div></div>`;
const topGuilds = (stats?.top_guilds || []).map(g => html`
<tr>
<td><div class="flex items-center gap-1">${g.icon ? `<img src="${g.icon}" style="width:24px;height:24px;border-radius:var(--radius-sm);object-fit:cover">` : `<div style="width:24px;height:24px;border-radius:var(--radius-sm);background:var(--accent-soft);display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:0.7rem;font-weight:600">${g.name.charAt(0)}</div>`}<span>${g.name}</span></div></td>
<td>${formatNumber(g.members)}</td>
</tr>
`).join("");
return html`
<div class="page-header">
<div class="flex items-center gap-1">
<img src="${info.avatar}" style="width:36px;height:36px;border-radius:50%;margin-right:0.5rem">
<div>
<h1>${info.name}</h1>
<p><span class="status-dot online"></span> Online · ${formatUptime(info.uptime_seconds)} uptime · ${info.latency_ms}ms latency</p>
</div>
</div>
</div>
<div class="page-body">
<div class="stats-grid mb-2">
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-server"></i></div>
<div class="stat-value">${formatNumber(info.guild_count)}</div>
<div class="stat-label">Servers</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-users"></i></div>
<div class="stat-value">${formatNumber(info.user_count)}</div>
<div class="stat-label">Users</div>
</div>
<div class="stat-card">
<div class="stat-icon cyan"><i class="fas fa-hashtag"></i></div>
<div class="stat-value">${formatNumber(info.channel_count)}</div>
<div class="stat-label">Channels</div>
</div>
<div class="stat-card">
<div class="stat-icon yellow"><i class="fas fa-puzzle-piece"></i></div>
<div class="stat-value">${info.cog_count}</div>
<div class="stat-label">Cogs Loaded</div>
</div>
<div class="stat-card">
<div class="stat-icon red"><i class="fas fa-terminal"></i></div>
<div class="stat-value">${formatNumber(info.command_count)}</div>
<div class="stat-label">Commands</div>
</div>
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-envelope"></i></div>
<div class="stat-value">${formatNumber(stats?.message_count || 0)}</div>
<div class="stat-label">Messages Tracked</div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-crown"></i> Top Servers</h3>
</div>
<div class="card-body">
<div class="table-container">
<table>
<thead><tr><th>Server</th><th>Members</th></tr></thead>
<tbody>${topGuilds || '<tr><td colspan="2" class="text-muted text-sm">No data</td></tr>'}</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-info-circle"></i> Bot Details</h3>
</div>
<div class="card-body">
<table>
<tr><td class="text-muted">Bot ID</td><td class="font-mono text-sm">${info.id}</td></tr>
<tr><td class="text-muted">Prefixes</td><td class="font-mono text-sm">${(info.prefixes || []).map(p => `<span class="badge badge-primary">${p}</span>`).join(" ")}</td></tr>
<tr><td class="text-muted">Red Version</td><td>${info.red_version}</td></tr>
<tr><td class="text-muted">Owner</td><td>${info.owner?.name || "Unknown"}</td></tr>
<tr><td class="text-muted">Latency</td><td><span class="badge ${info.latency_ms < 100 ? 'badge-success' : info.latency_ms < 300 ? 'badge-warning' : 'badge-danger'}">${info.latency_ms}ms</span></td></tr>
</table>
</div>
</div>
</div>
</div>`;
}
// ── Page: Guilds ───────────────────────────────────────────────────────
async function renderGuilds() {
const data = await api.get("/guilds");
if (data) state.guilds = data.guilds;
const guilds = state.guilds;
const list = guilds.map(g => html`
<div class="cog-card" style="cursor:pointer" data-guild="${g.id}">
<div class="cog-card-header">
<h4>${guildIcon(g, 28)} ${g.name}</h4>
</div>
<p>${formatNumber(g.member_count)} members · ${g.channel_count} channels · ${g.role_count} roles</p>
<button class="btn btn-primary btn-sm" data-guild="${g.id}"><i class="fas fa-cog"></i> Manage</button>
</div>
`).join("");
return html`
<div class="page-header">
<h1>Servers</h1>
<p>Select a server to manage</p>
</div>
<div class="page-body">
<div class="cog-grid">
${list || '<div class="empty-state"><i class="fas fa-server"></i><h3>No servers</h3><p>You don\'t have manage permissions on any servers this bot is in.</p></div>'}
</div>
</div>`;
}
// ── Page: Guild Overview ───────────────────────────────────────────────
async function renderGuildOverview(guildId) {
const guild = await api.get(`/guilds/${guildId}`);
if (!guild) return `<div class="page-body"><div class="empty-state"><i class="fas fa-exclamation-triangle"></i><h3>Guild not found</h3></div></div>`;
const features = (guild.features || []).map(f => `<span class="badge badge-muted">${f.replace(/_/g, " ")}</span>`).join(" ");
return html`
<div class="page-header">
<div class="guild-hero">
${guild.icon ? `<img class="guild-hero-icon" src="${guild.icon}">` : `<div class="guild-hero-icon-placeholder">${guild.name.charAt(0)}</div>`}
<div class="guild-hero-info">
<h2>${guild.name}</h2>
<div class="guild-meta">
<span><i class="fas fa-users"></i> ${formatNumber(guild.member_count)} members</span>
<span><i class="fas fa-hashtag"></i> ${guild.text_channels} text · ${guild.voice_channels} voice</span>
<span><i class="fas fa-at"></i> ${guild.role_count} roles</span>
<span><i class="fas fa-smile"></i> ${guild.emoji_count} emojis</span>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="stats-grid mb-2">
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-users"></i></div>
<div class="stat-value">${formatNumber(guild.member_count)}</div>
<div class="stat-label">Members</div>
</div>
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-hashtag"></i></div>
<div class="stat-value">${guild.text_channels + guild.voice_channels}</div>
<div class="stat-label">Channels</div>
</div>
<div class="stat-card">
<div class="stat-icon yellow"><i class="fas fa-gem"></i></div>
<div class="stat-value">${guild.boost_count}</div>
<div class="stat-label">Boosts (Level ${guild.boost_level})</div>
</div>
<div class="stat-card">
<div class="stat-icon cyan"><i class="fas fa-at"></i></div>
<div class="stat-value">${guild.role_count}</div>
<div class="stat-label">Roles</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-info-circle"></i> Server Details</h3></div>
<div class="card-body">
<table>
<tr><td class="text-muted" style="width:180px">Server ID</td><td class="font-mono text-sm">${guild.id}</td></tr>
<tr><td class="text-muted">Owner</td><td>${guild.owner?.name || "Unknown"}</td></tr>
<tr><td class="text-muted">Created</td><td>${new Date(guild.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</td></tr>
<tr><td class="text-muted">Boost Level</td><td><span class="badge badge-info">Level ${guild.boost_level}</span> (${guild.boost_count} boosts)</td></tr>
${features ? `<tr><td class="text-muted">Features</td><td>${features}</td></tr>` : ""}
</table>
</div>
</div>
</div>`;
}
// ── Page: Guild Settings ───────────────────────────────────────────────
async function renderGuildSettings(guildId) {
const [settings, roles, channels] = await Promise.all([
api.get(`/guilds/${guildId}/settings`),
api.get(`/guilds/${guildId}/roles`),
api.get(`/guilds/${guildId}/channels`),
]);
if (!settings) return "";
const roleOptions = (roles?.roles || [])
.filter(r => r.name !== "@everyone" && !r.managed)
.map(r => `<option value="${r.id}" ${(settings.admin_roles || []).includes(r.id) ? "selected" : ""}>${r.name}</option>`)
.join("");
const modRoleOptions = (roles?.roles || [])
.filter(r => r.name !== "@everyone" && !r.managed)
.map(r => `<option value="${r.id}" ${(settings.mod_roles || []).includes(r.id) ? "selected" : ""}>${r.name}</option>`)
.join("");
return html`
<div class="page-header">
<h1>Bot Settings</h1>
<p>Configure bot behavior for this server</p>
</div>
<div class="page-body">
<div class="card">
<div class="card-header"><h3><i class="fas fa-cog"></i> General Settings</h3></div>
<div class="card-body">
<form id="guild-settings-form" class="settings-form">
<div class="form-group">
<label class="form-label">Bot Nickname</label>
<input class="form-input" name="bot_nickname" value="${settings.bot_nickname || ""}" placeholder="Leave empty for default">
<div class="form-hint">The bot's nickname in this server</div>
</div>
<div class="form-group">
<label class="form-label">Prefixes</label>
<input class="form-input" name="prefixes" value="${(settings.prefixes || []).join(", ")}" placeholder="!, ?, .">
<div class="form-hint">Comma-separated list of command prefixes</div>
</div>
<div class="form-group">
<label class="form-label">Admin Roles</label>
<select class="form-select" name="admin_roles" multiple style="min-height:80px">
${roleOptions}
</select>
<div class="form-hint">Roles that can use admin commands (Ctrl+Click to multi-select)</div>
</div>
<div class="form-group">
<label class="form-label">Mod Roles</label>
<select class="form-select" name="mod_roles" multiple style="min-height:80px">
${modRoleOptions}
</select>
<div class="form-hint">Roles that can use mod commands</div>
</div>
<button type="submit" class="btn btn-primary mt-2"><i class="fas fa-save"></i> Save Settings</button>
</form>
</div>
</div>
</div>`;
}
// ── Page: Guild Commands ───────────────────────────────────────────────
async function renderGuildCommands(guildId) {
const data = await api.get(`/guilds/${guildId}/commands`);
if (!data) return "";
const cogs = Object.values(data.cogs || {});
const accordions = cogs.map(cog => {
const cmds = (cog.commands || []).filter(c => !c.parent);
return html`
<div class="accordion-item">
<div class="accordion-header">
<h4><i class="fas fa-puzzle-piece"></i> ${cog.name} <span class="badge badge-muted" style="margin-left:0.5rem">${cmds.length}</span></h4>
<span class="text-xs text-muted" style="margin-right:1rem">${cog.description}</span>
<i class="fas fa-chevron-down arrow"></i>
</div>
<div class="accordion-body">
<div class="accordion-body-inner">
${cmds.map(cmd => html`
<div class="command-row">
<div class="command-info">
<div class="command-name">${cmd.name}${cmd.signature ? ` <span class="text-muted text-xs">${cmd.signature}</span>` : ""}</div>
<div class="command-desc">${cmd.description || "No description"}</div>
${cmd.aliases.length ? `<div class="command-aliases">${cmd.aliases.map(a => `<span>${a}</span>`).join("")}</div>` : ""}
</div>
<label class="toggle">
<input type="checkbox" ${cmd.enabled ? "checked" : ""} data-cmd="${cmd.name}" data-guild="${guildId}">
<span class="slider"></span>
</label>
</div>
`).join("")}
</div>
</div>
</div>`;
}).join("");
return html`
<div class="page-header">
<div class="flex items-center justify-between">
<div>
<h1>Commands</h1>
<p>Enable or disable commands for this server</p>
</div>
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search commands..." id="cmd-search">
</div>
</div>
</div>
<div class="page-body">
<div id="cmd-accordion">${accordions}</div>
</div>`;
}
// ── Page: Third Parties ────────────────────────────────────────────────
async function renderThirdParties(guildId) {
const data = await api.get(`/guilds/${guildId}/third-parties`);
if (!data) return "";
const tps = Object.values(data.third_parties || {});
if (!tps.length) {
return html`
<div class="page-header"><h1>Third-Party Cog Settings</h1><p>Cogs with dashboard integration</p></div>
<div class="page-body">
<div class="empty-state">
<i class="fas fa-puzzle-piece"></i>
<h3>No Third Parties</h3>
<p>No cogs have registered dashboard pages yet. Cog developers can add dashboard support using the EveDash SDK.</p>
</div>
</div>`;
}
const cards = tps.map(tp => {
const pages = Object.values(tp.pages || {});
return html`
<div class="cog-card">
<div class="cog-card-header">
<h4><i class="fas fa-puzzle-piece"></i> ${tp.name}</h4>
<span class="badge badge-primary">${pages.length} page${pages.length !== 1 ? "s" : ""}</span>
</div>
<p>${tp.description}</p>
<div class="cog-card-actions">
${pages.map(p => html`
<button class="btn btn-ghost btn-sm" data-tp="${tp.name}" data-tp-page="${p.name}" data-guild="${guildId}">
<i class="${p.icon || 'fas fa-cog'}"></i> ${p.description || p.name}
</button>
`).join("")}
</div>
</div>`;
}).join("");
return html`
<div class="page-header"><h1>Third-Party Cog Settings</h1><p>Cogs with dashboard integration</p></div>
<div class="page-body">
<div class="cog-grid">${cards}</div>
<div id="tp-content" class="mt-3"></div>
</div>`;
}
async function loadThirdPartyPage(guildId, cogName, pageName) {
const data = await api.get(`/guilds/${guildId}/third-parties/${cogName}/${pageName}`);
if (!data || data.status === 1) {
toast(data?.error || "Failed to load page", "error");
return;
}
const wc = data.web_content;
if (!wc || !wc.settings) return;
const formFields = wc.settings.map(s => renderSettingField(s)).join("");
const container = document.getElementById("tp-content");
if (container) {
container.innerHTML = html`
<div class="card">
<div class="card-header"><h3><i class="fas fa-cog"></i> ${cogName} — ${pageName}</h3></div>
<div class="card-body">
<form class="settings-form" id="tp-form" data-cog="${cogName}" data-page="${pageName}" data-guild="${guildId}">
${formFields}
<button type="submit" class="btn btn-primary mt-2"><i class="fas fa-save"></i> Save</button>
</form>
</div>
</div>`;
}
}
function renderSettingField(s) {
switch (s.type) {
case "toggle":
return html`<div class="form-group"><div class="toggle-wrapper"><label class="toggle"><input type="checkbox" name="${s.id}" ${s.value ? "checked" : ""}><span class="slider"></span></label><div><div class="form-label" style="margin-bottom:0">${s.label}</div>${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div></div></div>`;
case "textarea":
return html`<div class="form-group"><label class="form-label">${s.label}</label><textarea class="form-textarea" name="${s.id}" placeholder="${s.placeholder || ""}">${s.value || ""}</textarea>${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
case "number":
return html`<div class="form-group"><label class="form-label">${s.label}</label><input class="form-input" type="number" name="${s.id}" value="${s.value || ""}" min="${s.min || ""}" max="${s.max || ""}" step="${s.step || 1}">${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
case "select":
return html`<div class="form-group"><label class="form-label">${s.label}</label><select class="form-select" name="${s.id}">${(s.options || []).map(o => `<option value="${o.value}" ${o.value == s.value ? "selected" : ""}>${o.label}</option>`).join("")}</select>${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
case "channel_select":
return html`<div class="form-group"><label class="form-label">${s.label}</label><input class="form-input" name="${s.id}" value="${s.value || ""}" placeholder="Channel ID">${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
case "role_select":
return html`<div class="form-group"><label class="form-label">${s.label}</label><input class="form-input" name="${s.id}" value="${s.value || ""}" placeholder="Role ID">${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
case "color":
return html`<div class="form-group"><label class="form-label">${s.label}</label><input class="form-input" type="color" name="${s.id}" value="${s.value || "#5865F2"}">${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
default: // text
return html`<div class="form-group"><label class="form-label">${s.label}</label><input class="form-input" name="${s.id}" value="${s.value || ""}" placeholder="${s.placeholder || ""}">${s.description ? `<div class="form-hint">${s.description}</div>` : ""}</div>`;
}
}
// ── Page: Cog Management ───────────────────────────────────────────────
async function renderCogs() {
const data = await api.get("/cogs");
if (!data) return "";
const loaded = Object.values(data.loaded || {}).sort((a, b) => a.name.localeCompare(b.name));
const available = (data.available || []).sort((a, b) => a.name.localeCompare(b.name));
const loadedCards = loaded.map(c => html`
<div class="cog-card">
<div class="cog-card-header">
<h4><i class="fas fa-cube" style="color:var(--success)"></i> ${c.name}</h4>
<span class="badge badge-success">Loaded</span>
</div>
<p>${c.description || "No description"}</p>
<p class="text-xs text-muted">${(c.commands || []).length} commands</p>
<div class="cog-card-actions mt-1">
<button class="btn btn-ghost btn-sm" data-cog-action="reload" data-cog="${c.name}"><i class="fas fa-sync-alt"></i> Reload</button>
${c.name !== "EveDash" ? `<button class="btn btn-danger btn-sm" data-cog-action="unload" data-cog="${c.name}"><i class="fas fa-stop"></i> Unload</button>` : ""}
</div>
</div>
`).join("");
const availableCards = available.map(c => html`
<div class="cog-card">
<div class="cog-card-header">
<h4><i class="fas fa-cube" style="color:var(--text-muted)"></i> ${c.name}</h4>
<span class="badge badge-muted">Unloaded</span>
</div>
<p class="text-xs text-muted">Repo: ${c.repo || "unknown"}</p>
<div class="cog-card-actions mt-1">
<button class="btn btn-primary btn-sm" data-cog-action="load" data-cog="${c.name}"><i class="fas fa-play"></i> Load</button>
</div>
</div>
`).join("");
return html`
<div class="page-header">
<h1>Cog Management</h1>
<p>Load, unload, and reload cogs</p>
</div>
<div class="page-body">
<h3 class="mb-1" style="font-size:1rem"><i class="fas fa-check-circle" style="color:var(--success)"></i> Loaded (${loaded.length})</h3>
<div class="cog-grid mb-2">${loadedCards}</div>
${available.length ? html`<h3 class="mb-1" style="font-size:1rem"><i class="fas fa-circle" style="color:var(--text-muted)"></i> Available (${available.length})</h3><div class="cog-grid">${availableCards}</div>` : ""}
</div>`;
}
// ── Page: Admin Panel ──────────────────────────────────────────────────
async function renderAdmin() {
const [config, blacklist] = await Promise.all([
api.get("/admin/config"),
api.get("/admin/blacklist"),
]);
if (!config) return "";
const blRows = (blacklist?.blacklist || []).map(u => html`
<tr>
<td><div class="flex items-center gap-1">${u.avatar ? `<img src="${u.avatar}" style="width:24px;height:24px;border-radius:50%">` : ""}<span>${u.name}</span></div></td>
<td class="font-mono text-sm">${u.id}</td>
<td><button class="btn btn-danger btn-sm" data-bl-remove="${u.id}"><i class="fas fa-times"></i></button></td>
</tr>
`).join("");
return html`
<div class="page-header"><h1>Admin Panel</h1><p>Bot-wide configuration (owner only)</p></div>
<div class="page-body">
<div class="grid-2">
<div class="card">
<div class="card-header"><h3><i class="fas fa-sliders-h"></i> Global Config</h3></div>
<div class="card-body">
<form id="admin-config-form" class="settings-form">
<div class="form-group">
<label class="form-label">Global Prefixes</label>
<input class="form-input" name="global_prefixes" value="${(config.global_prefixes || []).join(", ")}">
<div class="form-hint">Comma-separated</div>
</div>
<div class="form-group">
<div class="toggle-wrapper">
<label class="toggle"><input type="checkbox" name="embeds" ${config.embeds ? "checked" : ""}><span class="slider"></span></label>
<div><div class="form-label" style="margin-bottom:0">Embeds</div><div class="form-hint">Use embeds for responses</div></div>
</div>
</div>
<div class="form-group">
<div class="toggle-wrapper">
<label class="toggle"><input type="checkbox" name="fuzzy" ${config.fuzzy ? "checked" : ""}><span class="slider"></span></label>
<div><div class="form-label" style="margin-bottom:0">Fuzzy Command Search</div><div class="form-hint">Suggest similar commands on typo</div></div>
</div>
</div>
<div class="form-group">
<div class="toggle-wrapper">
<label class="toggle"><input type="checkbox" name="invite_public" ${config.invite_public ? "checked" : ""}><span class="slider"></span></label>
<div><div class="form-label" style="margin-bottom:0">Public Invite</div><div class="form-hint">Allow anyone to get the invite link</div></div>
</div>
</div>
<div class="form-group">
<label class="form-label">Locale</label>
<input class="form-input" name="locale" value="${config.locale || "en-US"}">
</div>
<button type="submit" class="btn btn-primary mt-1"><i class="fas fa-save"></i> Save</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-ban"></i> Dashboard Blacklist</h3>
<button class="btn btn-ghost btn-sm" id="bl-add-btn"><i class="fas fa-plus"></i> Add</button>
</div>
<div class="card-body">
${blRows ? html`<div class="table-container"><table><thead><tr><th>User</th><th>ID</th><th></th></tr></thead><tbody>${blRows}</tbody></table></div>` : '<div class="empty-state" style="padding:1.5rem"><i class="fas fa-check-circle" style="font-size:1.5rem;color:var(--success)"></i><p class="text-sm text-muted mt-1">No blacklisted users</p></div>'}
</div>
</div>
</div>
</div>`;
}
// ── Main Render ────────────────────────────────────────────────────────
async function render() {
const route = getRoute();
const app = document.getElementById("app");
// Handle OAuth callback
if (route.startsWith("/callback")) {
const params = parseQuery(location.hash);
if (params.token) {
localStorage.setItem("eve_token", params.token);
state.token = params.token;
}
navigate("/home");
return;
}
// Check auth
if (route !== "/login") {
const authed = await checkAuth();
if (!authed) {
navigate("/login");
return;
}
}
if (route === "/login") {
app.innerHTML = renderLogin();
hideLoading();
return;
}
// Load guilds if not loaded
if (!state.guilds.length) {
const data = await api.get("/guilds");
if (data) state.guilds = data.guilds;
}
// Determine page content
let content = "";
const guildMatch = route.match(/^\/guild\/(\d+)\/(.*)/);
if (guildMatch) {
const guildId = guildMatch[1];
const subPage = guildMatch[2];
state.selectedGuild = state.guilds.find(g => g.id === guildId) || { id: guildId, name: "Unknown", member_count: 0 };
switch (subPage) {
case "overview": content = await renderGuildOverview(guildId); break;
case "settings": content = await renderGuildSettings(guildId); break;
case "commands": content = await renderGuildCommands(guildId); break;
case "third-parties": content = await renderThirdParties(guildId); break;
default: content = await renderGuildOverview(guildId);
}
} else {
switch (route) {
case "/": case "/home": content = await renderHome(); break;
case "/guilds": content = await renderGuilds(); break;
case "/cogs": content = await renderCogs(); break;
case "/admin": content = await renderAdmin(); break;
default: content = await renderHome();
}
}
app.innerHTML = `
${renderSidebar()}
<button class="mobile-menu-btn" id="mobile-menu"><i class="fas fa-bars"></i></button>
<main class="main-content ${state.sidebarCollapsed ? 'sidebar-collapsed' : ''}">
${content}
</main>
`;
hideLoading();
bindEvents();
connectWS();
}
// ── Event Bindings ─────────────────────────────────────────────────────
function bindEvents() {
// Sidebar nav
document.querySelectorAll("[data-nav]").forEach(el => {
el.addEventListener("click", () => navigate(el.dataset.nav));
});
// Sidebar toggle
const toggleBtn = document.getElementById("sidebar-toggle");
if (toggleBtn) {
toggleBtn.addEventListener("click", () => {
state.sidebarCollapsed = !state.sidebarCollapsed;
localStorage.setItem("sidebar_collapsed", state.sidebarCollapsed);
render();
});
}
// Mobile menu
const mobileBtn = document.getElementById("mobile-menu");
if (mobileBtn) {
mobileBtn.addEventListener("click", () => {
const sidebar = document.getElementById("sidebar");
if (sidebar) sidebar.classList.toggle("mobile-open");
});
}
// Guild selector dropdown
const guildSel = document.getElementById("guild-selector");
const guildDd = document.getElementById("guild-dropdown");
if (guildSel && guildDd) {
guildSel.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = guildDd.classList.contains("open");
if (isOpen) {
guildDd.classList.remove("open");
} else {
// Populate dropdown
guildDd.innerHTML = `
<input class="guild-dropdown-search" placeholder="Search servers..." id="guild-search-input">
${state.guilds.map(g => `
<div class="guild-dropdown-item ${state.selectedGuild?.id === g.id ? 'active' : ''}" data-guild="${g.id}">
${g.icon ? `<img src="${g.icon}">` : `<div class="placeholder-icon">${g.name.charAt(0)}</div>`}
<span>${g.name}</span>
</div>
`).join("")}
`;
guildDd.classList.add("open");
// Search filter
const searchInput = document.getElementById("guild-search-input");
if (searchInput) {
searchInput.focus();
searchInput.addEventListener("input", (e) => {
const q = e.target.value.toLowerCase();
guildDd.querySelectorAll(".guild-dropdown-item").forEach(item => {
const name = item.querySelector("span").textContent.toLowerCase();
item.style.display = name.includes(q) ? "" : "none";
});
});
searchInput.addEventListener("click", (e) => e.stopPropagation());
}
// Guild selection
guildDd.querySelectorAll(".guild-dropdown-item").forEach(item => {
item.addEventListener("click", (e) => {
e.stopPropagation();
const gid = item.dataset.guild;
state.selectedGuild = state.guilds.find(g => g.id === gid);
guildDd.classList.remove("open");
navigate(`/guild/${gid}/overview`);
});
});
}
});
// Close dropdown on outside click
document.addEventListener("click", () => guildDd.classList.remove("open"));
}
// Guild cards on guilds page
document.querySelectorAll("[data-guild]").forEach(el => {
if (el.classList.contains("guild-dropdown-item") || el.classList.contains("guild-selector")) return;
el.addEventListener("click", (e) => {
const gid = el.dataset.guild || e.target.closest("[data-guild]")?.dataset.guild;
if (gid) {
state.selectedGuild = state.guilds.find(g => g.id === gid);
navigate(`/guild/${gid}/overview`);
}
});
});
// Command toggles
document.querySelectorAll("[data-cmd]").forEach(el => {
el.addEventListener("change", async () => {
const cmd = el.dataset.cmd;
const gid = el.dataset.guild;
const result = await api.put(`/guilds/${gid}/commands/${cmd}`, { enabled: el.checked });
if (result) toast(`${cmd} ${el.checked ? "enabled" : "disabled"}`, "success");
});
});
// Accordion
document.querySelectorAll(".accordion-header").forEach(el => {
el.addEventListener("click", () => {
el.parentElement.classList.toggle("open");
});
});
// Command search
const cmdSearch = document.getElementById("cmd-search");
if (cmdSearch) {
cmdSearch.addEventListener("input", (e) => {
const q = e.target.value.toLowerCase();
document.querySelectorAll(".accordion-item").forEach(item => {
const cmds = item.querySelectorAll(".command-row");
let visible = 0;
cmds.forEach(row => {
const name = row.querySelector(".command-name")?.textContent.toLowerCase() || "";
const show = !q || name.includes(q);
row.style.display = show ? "" : "none";
if (show) visible++;
});
item.style.display = visible || !q ? "" : "none";
if (q && visible) item.classList.add("open");
});
});
}
// Guild settings form
const guildForm = document.getElementById("guild-settings-form");
if (guildForm) {
guildForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(guildForm);
const gid = getRoute().match(/\/guild\/(\d+)/)?.[1];
if (!gid) return;
const prefixes = fd.get("prefixes").split(",").map(s => s.trim()).filter(Boolean);
const adminRoles = Array.from(guildForm.querySelector('[name="admin_roles"]')?.selectedOptions || []).map(o => o.value);
const modRoles = Array.from(guildForm.querySelector('[name="mod_roles"]')?.selectedOptions || []).map(o => o.value);
const result = await api.put(`/guilds/${gid}/settings`, {
bot_nickname: fd.get("bot_nickname"),
prefixes,
admin_roles: adminRoles,
mod_roles: modRoles,
});
if (result) toast("Settings saved!", "success");
});
}
// Cog actions
document.querySelectorAll("[data-cog-action]").forEach(el => {
el.addEventListener("click", async () => {
const action = el.dataset.cogAction;
const cog = el.dataset.cog;
if (action === "unload" && !confirm(`Unload ${cog}?`)) return;
const result = await api.post(`/cogs/${cog}/${action}`);
if (result) { toast(result.message || `${cog} ${action}ed`, "success"); render(); }
});
});
// Third-party page buttons
document.querySelectorAll("[data-tp]").forEach(el => {
if (!el.dataset.tpPage) return;
el.addEventListener("click", () => {
loadThirdPartyPage(el.dataset.guild, el.dataset.tp, el.dataset.tpPage);
});
});
// Third-party form
document.addEventListener("submit", async (e) => {
if (e.target.id !== "tp-form") return;
e.preventDefault();
const form = e.target;
const cogName = form.dataset.cog;
const page = form.dataset.page;
const gid = form.dataset.guild;
const data = {};
new FormData(form).forEach((v, k) => {
const input = form.querySelector(`[name="${k}"]`);
if (input && input.type === "checkbox") {
data[k] = input.checked;
} else {
data[k] = v;
}
});
// Include unchecked checkboxes
form.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (!data.hasOwnProperty(cb.name)) data[cb.name] = false;
});
const result = await api.post(`/guilds/${gid}/third-parties/${cogName}/${page}`, data);
if (result && result.status === 0) {
toast("Settings saved!", "success");
if (result.notifications) {
result.notifications.forEach(n => toast(n.message, n.type || "info"));
}
}
});
// Admin config form
const adminForm = document.getElementById("admin-config-form");
if (adminForm) {
adminForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(adminForm);
const result = await api.put("/admin/config", {
global_prefixes: fd.get("global_prefixes").split(",").map(s => s.trim()).filter(Boolean),
embeds: adminForm.querySelector('[name="embeds"]').checked,
fuzzy: adminForm.querySelector('[name="fuzzy"]').checked,
invite_public: adminForm.querySelector('[name="invite_public"]').checked,
locale: fd.get("locale"),
});
if (result) toast("Config saved!", "success");
});
}
// Blacklist remove
document.querySelectorAll("[data-bl-remove]").forEach(el => {
el.addEventListener("click", async () => {
const uid = el.dataset.blRemove;
await api.del(`/admin/blacklist/${uid}`);
toast("User removed from blacklist", "success");
render();
});
});
// Blacklist add
const blAddBtn = document.getElementById("bl-add-btn");
if (blAddBtn) {
blAddBtn.addEventListener("click", async () => {
const uid = prompt("Enter user ID to blacklist:");
if (!uid) return;
await api.post("/admin/blacklist", { user_id: uid });
toast("User added to blacklist", "success");
render();
});
}
}
function hideLoading() {
const ls = document.getElementById("loading-screen");
if (ls) ls.classList.add("hidden");
}
// ── Global exports ─────────────────────────────────────────────────────
window.__login = login;
window.__logout = logout;
// ── Init ───────────────────────────────────────────────────────────────
state.sidebarCollapsed = localStorage.getItem("sidebar_collapsed") === "true";
window.addEventListener("hashchange", render);
render();
})();