/* ═══════════════════════════════════════════════════════════════════════
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 = `${msg}`;
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 `
`;
return `
${guild.name.charAt(0).toUpperCase()}
`;
}
// ── Page: Login ────────────────────────────────────────────────────────
function renderLogin() {
return html`
EveDash
Manage your Red-DiscordBot from a sleek web interface
Guild Management
Cog Control
Command Toggles
Real-time Updates
Third-party Settings
Bot Owner Admin
`;
}
// ── Sidebar ────────────────────────────────────────────────────────────
function renderSidebar() {
const u = state.user;
const g = state.selectedGuild;
const isOwner = u && u.is_owner;
const route = getRoute();
const guildNav = g ? html`
Server
Overview
Settings
Commands
Third Parties
` : "";
const adminNav = isOwner ? html`
Admin
Cog Management
Admin Panel
` : "";
return html`
`;
}
// ── 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 `Error
Could not load bot info.
`;
const topGuilds = (stats?.top_guilds || []).map(g => html`
${g.icon ? `  ` : ` ${g.name.charAt(0)} `} ${g.name} |
${formatNumber(g.members)} |
`).join("");
return html`
${formatNumber(info.guild_count)}
Servers
${formatNumber(info.user_count)}
Users
${formatNumber(info.channel_count)}
Channels
${info.cog_count}
Cogs Loaded
${formatNumber(info.command_count)}
Commands
${formatNumber(stats?.message_count || 0)}
Messages Tracked
| Server | Members |
${topGuilds || '| No data |
'}
| Bot ID | ${info.id} |
| Prefixes | ${(info.prefixes || []).map(p => `${p}`).join(" ")} |
| Red Version | ${info.red_version} |
| Owner | ${info.owner?.name || "Unknown"} |
| Latency | ${info.latency_ms}ms |
`;
}
// ── 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`
${formatNumber(g.member_count)} members · ${g.channel_count} channels · ${g.role_count} roles
`).join("");
return html`
${list || '
No servers
You don\'t have manage permissions on any servers this bot is in.
'}
`;
}
// ── Page: Guild Overview ───────────────────────────────────────────────
async function renderGuildOverview(guildId) {
const guild = await api.get(`/guilds/${guildId}`);
if (!guild) return ``;
const features = (guild.features || []).map(f => `${f.replace(/_/g, " ")}`).join(" ");
return html`
${formatNumber(guild.member_count)}
Members
${guild.text_channels + guild.voice_channels}
Channels
${guild.boost_count}
Boosts (Level ${guild.boost_level})
${guild.role_count}
Roles
| Server ID | ${guild.id} |
| Owner | ${guild.owner?.name || "Unknown"} |
| Created | ${new Date(guild.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} |
| Boost Level | Level ${guild.boost_level} (${guild.boost_count} boosts) |
${features ? `| Features | ${features} |
` : ""}
`;
}
// ── 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 => ``)
.join("");
const modRoleOptions = (roles?.roles || [])
.filter(r => r.name !== "@everyone" && !r.managed)
.map(r => ``)
.join("");
return html`
`;
}
// ── 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`
${cmds.map(cmd => html`
${cmd.name}${cmd.signature ? ` ${cmd.signature}` : ""}
${cmd.description || "No description"}
${cmd.aliases.length ? `
${cmd.aliases.map(a => `${a}`).join("")}
` : ""}
`).join("")}
`;
}).join("");
return html`
`;
}
// ── 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`
No Third Parties
No cogs have registered dashboard pages yet. Cog developers can add dashboard support using the EveDash SDK.
`;
}
const cards = tps.map(tp => {
const pages = Object.values(tp.pages || {});
return html`
${tp.description}
${pages.map(p => html`
`).join("")}
`;
}).join("");
return html`
`;
}
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`
`;
}
}
function renderSettingField(s) {
switch (s.type) {
case "toggle":
return html``;
case "textarea":
return html``;
case "number":
return html``;
case "select":
return html``;
case "channel_select":
return html``;
case "role_select":
return html``;
case "color":
return html``;
default: // text
return html``;
}
}
// ── 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`
${c.description || "No description"}
${(c.commands || []).length} commands
${c.name !== "EveDash" ? `` : ""}
`).join("");
const availableCards = available.map(c => html`
Repo: ${c.repo || "unknown"}
`).join("");
return html`
Loaded (${loaded.length})
${loadedCards}
${available.length ? html`
Available (${available.length})
${availableCards}
` : ""}
`;
}
// ── 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`
${u.avatar ? `  ` : ""} ${u.name} |
${u.id} |
|
`).join("");
return html`
`;
}
// ── 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()}
${content}
`;
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 = `
${state.guilds.map(g => `
${g.icon ? `

` : `
${g.name.charAt(0)}
`}
${g.name}
`).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();
})();