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
This commit is contained in:
EveCogs 2026-05-07 23:47:55 +00:00
parent f87759fecb
commit 20d6bd2c82
9 changed files with 3617 additions and 1 deletions

5
dashboard/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from .dashboard import EveDash
async def setup(bot):
await bot.add_cog(EveDash(bot))

335
dashboard/dashboard.py Normal file
View file

@ -0,0 +1,335 @@
"""
EveDash — A modern, self-contained web dashboard for Red-DiscordBot.
Inspired by AAA3A's Dashboard, rebuilt from the ground up with:
• Embedded aiohttp server (no external packages needed)
• Modern SPA frontend with Discord-inspired dark theme
• Real-time WebSocket updates
• Third-party cog integration SDK
"""
from __future__ import annotations
import asyncio
import logging
import secrets
import typing
import discord
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from .sdk import ThirdPartyManager
from .server import DashboardServer
log = logging.getLogger("red.evecogs.dashboard")
class EveDash(commands.Cog):
"""🖥️ A modern web dashboard for Red-DiscordBot.
Manage your bot from a beautiful web interface — configure guilds,
manage cogs, toggle commands, and more. Third-party cogs can register
their own settings pages using the EveDash SDK.
**Quick Setup:**
1. `[p]evedash setup` — guided setup wizard
2. Open the dashboard URL in your browser
3. Log in with Discord
**For cog developers:** See `dashboard/sdk.py` for the integration guide.
"""
__author__ = "EveCogs"
__version__ = "1.0.0"
def __init__(self, bot: Red) -> None:
self.bot = bot
self.config = Config.get_conf(
self,
identifier=784917823648172,
force_registration=True,
)
self.config.register_global(
host="0.0.0.0",
port=42356,
client_id=None,
client_secret=None,
redirect_uri=None,
secret_key=None,
blacklist=[],
support_url=None,
meta_title=None,
meta_description=None,
)
self.server: DashboardServer = DashboardServer(self)
self.third_parties: ThirdPartyManager = ThirdPartyManager(bot)
self._ready = asyncio.Event()
async def cog_load(self) -> None:
# Generate secret key if not set
if not await self.config.secret_key():
await self.config.secret_key.set(secrets.token_urlsafe(64))
asyncio.create_task(self._start_server())
async def _start_server(self) -> None:
await self.bot.wait_until_red_ready()
host = await self.config.host()
port = await self.config.port()
try:
await self.server.start(host, port)
self._ready.set()
self.bot.dispatch("dashboard_cog_add", self)
log.info(f"EveDash v{self.__version__} ready on http://{host}:{port}")
except Exception as e:
log.error(f"Failed to start EveDash server: {e}")
async def cog_unload(self) -> None:
self.bot.dispatch("dashboard_cog_remove", self)
await self.server.stop()
# ── Listeners ────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_cog_add(self, cog: commands.Cog) -> None:
"""Notify newly-loaded cogs that the dashboard is available."""
if not self._ready.is_set():
return
ev = "on_dashboard_cog_add"
for listener_name, listener_func in cog.get_listeners():
if listener_name == ev:
try:
self.bot._schedule_event(listener_func, ev, self)
except Exception:
pass
@commands.Cog.listener()
async def on_cog_remove(self, cog: commands.Cog) -> None:
"""Clean up when a third-party cog is unloaded."""
self.third_parties.remove_third_party(cog)
@commands.Cog.listener()
async def on_command(self, ctx: commands.Context) -> None:
self.server._command_count += 1
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
if not message.author.bot:
self.server._message_count += 1
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member) -> None:
await self.server.ws_manager.broadcast("member_join", {
"guild_id": str(member.guild.id),
"user": str(member),
})
@commands.Cog.listener()
async def on_member_remove(self, member: discord.Member) -> None:
await self.server.ws_manager.broadcast("member_remove", {
"guild_id": str(member.guild.id),
"user": str(member),
})
# ── Commands ─────────────────────────────────────────────────────────
@commands.group(name="evedash", aliases=["dashboard", "dash"])
async def evedash(self, ctx: commands.Context) -> None:
"""🖥️ EveDash — Web Dashboard for Red-DiscordBot."""
if ctx.invoked_subcommand is None:
port = await self.config.port()
redirect_uri = await self.config.redirect_uri()
if redirect_uri:
base_url = redirect_uri.rsplit("/api/", 1)[0]
embed = discord.Embed(
title="🖥️ EveDash",
description=f"Your bot's web dashboard is live!\n\n**[Open Dashboard]({base_url})**",
color=discord.Color.blurple(),
)
embed.add_field(name="Status", value="🟢 Online", inline=True)
embed.add_field(name="Port", value=str(port), inline=True)
embed.add_field(
name="Third Parties",
value=str(len(self.third_parties.third_parties)),
inline=True,
)
await ctx.send(embed=embed)
else:
await ctx.send(
"⚠️ EveDash is not configured yet.\n"
f"Run `{ctx.prefix}evedash setup` to get started!"
)
@evedash.command(name="setup")
@checks.is_owner()
async def evedash_setup(self, ctx: commands.Context) -> None:
"""🔧 Guided setup wizard for EveDash.
You'll need:
1. A Discord application with OAuth2 configured
2. Your Client ID and Client Secret
3. A redirect URI (your domain + /api/auth/callback)
"""
embed = discord.Embed(
title="🔧 EveDash Setup Wizard",
description=(
"Let's configure your dashboard!\n\n"
"**Step 1:** Go to the [Discord Developer Portal](https://discord.com/developers/applications)\n"
"**Step 2:** Select your bot's application\n"
"**Step 3:** Go to **OAuth2** and note your **Client ID** and **Client Secret**\n"
"**Step 4:** Add a redirect URI: `http://YOUR_DOMAIN:PORT/api/auth/callback`\n\n"
"Ready? Reply with your **Client ID** (just the number)."
),
color=discord.Color.blurple(),
)
await ctx.send(embed=embed)
def check(m):
return m.author == ctx.author and m.channel == ctx.channel
# Client ID
try:
msg = await self.bot.wait_for("message", check=check, timeout=120)
client_id = msg.content.strip()
await self.config.client_id.set(client_id)
await ctx.send("✅ Client ID saved! Now send your **Client Secret**:")
except asyncio.TimeoutError:
return await ctx.send("⏰ Setup timed out.")
# Client Secret
try:
msg = await self.bot.wait_for("message", check=check, timeout=120)
client_secret = msg.content.strip()
await self.config.client_secret.set(client_secret)
try:
await msg.delete()
except Exception:
pass
await ctx.send("✅ Client Secret saved (message deleted for safety)! Now send your **Redirect URI**:\n"
f"Example: `http://yourdomain.com:{await self.config.port()}/api/auth/callback`")
except asyncio.TimeoutError:
return await ctx.send("⏰ Setup timed out.")
# Redirect URI
try:
msg = await self.bot.wait_for("message", check=check, timeout=120)
redirect_uri = msg.content.strip()
await self.config.redirect_uri.set(redirect_uri)
except asyncio.TimeoutError:
return await ctx.send("⏰ Setup timed out.")
base_url = redirect_uri.rsplit("/api/", 1)[0]
embed = discord.Embed(
title="🎉 EveDash Setup Complete!",
description=(
f"Your dashboard is ready!\n\n"
f"**🌐 URL:** {base_url}\n"
f"**🔌 Port:** {await self.config.port()}\n\n"
f"Open the link above and log in with Discord.\n"
f"Only bot owners and server admins/managers can access it."
),
color=discord.Color.green(),
)
embed.set_footer(text="Tip: Use [p]evedash port <number> to change the port")
await ctx.send(embed=embed)
@evedash.command(name="port")
@checks.is_owner()
async def evedash_port(self, ctx: commands.Context, port: int) -> None:
"""Change the dashboard port. Requires a cog reload."""
if not 1024 <= port <= 65535:
return await ctx.send("❌ Port must be between 1024 and 65535.")
await self.config.port.set(port)
await ctx.send(f"✅ Port set to **{port}**. Reload the cog (`{ctx.prefix}reload dashboard`) for it to take effect.")
@evedash.command(name="host")
@checks.is_owner()
async def evedash_host(self, ctx: commands.Context, host: str) -> None:
"""Change the dashboard bind address (default: 0.0.0.0)."""
await self.config.host.set(host)
await ctx.send(f"✅ Host set to **{host}**. Reload the cog for it to take effect.")
@evedash.command(name="secret")
@checks.is_owner()
async def evedash_secret(self, ctx: commands.Context, *, secret: str) -> None:
"""Set the Discord OAuth2 client secret."""
await self.config.client_secret.set(secret)
try:
await ctx.message.delete()
except Exception:
pass
await ctx.send("✅ Client secret updated.")
@evedash.command(name="clientid")
@checks.is_owner()
async def evedash_clientid(self, ctx: commands.Context, client_id: str) -> None:
"""Set the Discord OAuth2 client ID."""
await self.config.client_id.set(client_id)
await ctx.send(f"✅ Client ID set to `{client_id}`.")
@evedash.command(name="redirect")
@checks.is_owner()
async def evedash_redirect(self, ctx: commands.Context, *, uri: str) -> None:
"""Set the OAuth2 redirect URI."""
if not uri.endswith("/api/auth/callback"):
return await ctx.send("⚠️ Redirect URI must end with `/api/auth/callback`.")
await self.config.redirect_uri.set(uri)
await ctx.send(f"✅ Redirect URI set to `{uri}`.")
@evedash.command(name="blacklist")
@checks.is_owner()
async def evedash_blacklist(self, ctx: commands.Context, user: discord.User) -> None:
"""Toggle a user on the dashboard blacklist."""
async with self.config.blacklist() as bl:
if user.id in bl:
bl.remove(user.id)
await ctx.send(f"✅ **{user}** removed from dashboard blacklist.")
else:
bl.append(user.id)
await ctx.send(f"✅ **{user}** added to dashboard blacklist.")
@evedash.command(name="info")
async def evedash_info(self, ctx: commands.Context) -> None:
"""Show dashboard status and info."""
redirect_uri = await self.config.redirect_uri()
port = await self.config.port()
host = await self.config.host()
tp_count = len(self.third_parties.third_parties)
embed = discord.Embed(
title="🖥️ EveDash Info",
color=discord.Color.blurple(),
)
embed.add_field(
name="Status",
value="🟢 Online" if self._ready.is_set() else "🔴 Offline",
inline=True,
)
embed.add_field(name="Host", value=f"`{host}:{port}`", inline=True)
embed.add_field(name="Third Parties", value=str(tp_count), inline=True)
embed.add_field(name="Version", value=f"v{self.__version__}", inline=True)
embed.add_field(
name="Messages Tracked",
value=f"{self.server._message_count:,}",
inline=True,
)
embed.add_field(
name="Commands Tracked",
value=f"{self.server._command_count:,}",
inline=True,
)
if redirect_uri:
base_url = redirect_uri.rsplit("/api/", 1)[0]
embed.add_field(name="URL", value=base_url, inline=False)
if tp_count:
tp_names = ", ".join(self.third_parties.third_parties.keys())
embed.add_field(name="Registered Cogs", value=tp_names, inline=False)
embed.set_footer(text="EveDash — Web Dashboard for Red-DiscordBot")
await ctx.send(embed=embed)

12
dashboard/info.json Normal file
View file

@ -0,0 +1,12 @@
{
"author": ["everestmcarthur"],
"install_msg": "**EveDash** — Web Dashboard for Red-DiscordBot\n\nRun `[p]evedash setup` to configure your dashboard.\nSee the README for full documentation.",
"name": "EveDash",
"short": "A modern, self-contained web dashboard for Red-DiscordBot.",
"description": "EveDash is a sleek, Discord-inspired web dashboard that lets you manage your Red bot from a browser. Features Discord OAuth2 login, guild management, cog control, command toggles, real-time WebSocket updates, and a powerful SDK that lets any cog register its own settings pages with just a few lines of code.",
"requirements": ["aiohttp"],
"tags": ["dashboard", "web", "admin", "management", "ui"],
"min_bot_version": "3.5.0",
"type": "COG",
"end_user_data_statement": "This cog stores Discord user IDs for authentication sessions and blacklist management. No message content is stored."
}

252
dashboard/sdk.py Normal file
View file

@ -0,0 +1,252 @@
"""
EveDash SDK — Add dashboard support to any Red-DiscordBot cog.
Quick Start
-----------
1. Import the decorator and listener mixin:
from dashboard.sdk import dashboard_page
2. Add a listener so the dashboard discovers your cog:
@commands.Cog.listener()
async def on_dashboard_cog_add(self, dashboard_cog):
dashboard_cog.rpc.add_third_party(self)
@commands.Cog.listener()
async def on_dashboard_cog_remove(self, dashboard_cog):
dashboard_cog.rpc.remove_third_party(self)
3. Decorate methods with @dashboard_page to expose them:
@dashboard_page(
name="settings",
description="Configure MyCog settings",
methods=("GET", "POST"),
is_owner=False,
)
async def dashboard_settings_page(self, user, guild, method="GET", data=None, **kwargs):
if method == "POST" and data:
# Save settings
await self.config.guild(guild).welcome_channel.set(data.get("welcome_channel"))
return {"status": 0, "notifications": [{"type": "success", "message": "Settings saved!"}]}
# Return settings schema for the dashboard to auto-render
current = await self.config.guild(guild).all()
return {
"status": 0,
"web_content": {
"source": "EveDash auto-form",
"settings": [
{
"id": "welcome_channel",
"type": "channel_select",
"label": "Welcome Channel",
"description": "Channel where welcome messages are sent",
"value": current.get("welcome_channel"),
},
{
"id": "welcome_message",
"type": "textarea",
"label": "Welcome Message",
"description": "Supports {user}, {server}, {member_count}",
"value": current.get("welcome_message", ""),
"placeholder": "Welcome {user} to {server}!",
},
{
"id": "enabled",
"type": "toggle",
"label": "Enable Welcome Messages",
"value": current.get("enabled", False),
},
{
"id": "auto_role",
"type": "role_select",
"label": "Auto Role",
"description": "Role assigned to new members",
"value": current.get("auto_role"),
},
{
"id": "log_channel",
"type": "channel_select",
"label": "Log Channel",
"value": current.get("log_channel"),
},
],
},
}
Setting Types
-------------
- ``text`` — Single-line text input
- ``textarea`` — Multi-line text input
- ``number`` — Numeric input (supports min/max)
- ``toggle`` — Boolean on/off switch
- ``select`` — Dropdown with options: [{"label": "...", "value": "..."}]
- ``multi_select`` — Multi-select dropdown
- ``channel_select`` — Discord channel picker
- ``role_select`` — Discord role picker
- ``color`` — Color picker
- ``slider`` — Range slider (supports min/max/step)
"""
from __future__ import annotations
import inspect
import typing
if typing.TYPE_CHECKING:
from redbot.core import commands
def dashboard_page(
name: str | None = None,
description: str | None = None,
methods: tuple[str, ...] = ("GET",),
is_owner: bool = False,
hidden: bool = False,
icon: str | None = None,
order: int = 0,
):
"""Decorator to register a cog method as a dashboard page.
Parameters
----------
name:
URL-safe page name (e.g. ``"settings"``). Defaults to function name.
description:
Human-readable page description shown in the dashboard sidebar.
methods:
HTTP methods this page handles (``"GET"``, ``"POST"``, etc.).
is_owner:
If ``True``, only bot owners can access this page.
hidden:
If ``True``, the page won't appear in navigation but is still accessible.
icon:
Icon class for sidebar (e.g. ``"fas fa-cog"``). Defaults to a gear icon.
order:
Sort order in the sidebar. Lower numbers appear first.
"""
def decorator(func: typing.Callable):
if not inspect.iscoroutinefunction(func):
raise TypeError("Dashboard page handler must be a coroutine function.")
func.__dashboard_params__ = {
"name": name or func.__name__,
"description": description or func.__doc__ or "No description",
"methods": methods,
"is_owner": is_owner,
"hidden": hidden,
"icon": icon or "fas fa-puzzle-piece",
"order": order,
}
return func
return decorator
class ThirdPartyManager:
"""Manages third-party cog registrations for the dashboard."""
def __init__(self, bot) -> None:
self.bot = bot
self.third_parties: dict[str, dict] = {}
self.third_party_cogs: dict[str, commands.Cog] = {}
def add_third_party(self, cog) -> None:
"""Register a cog's dashboard pages."""
name = cog.qualified_name
pages: dict[str, dict] = {}
for attr_name in dir(cog):
try:
func = getattr(cog, attr_name)
if hasattr(func, "__dashboard_params__"):
params = func.__dashboard_params__
page_name = params["name"]
pages[page_name] = {
"func": func,
"params": params,
}
except (TypeError, AttributeError):
continue
if not pages:
return
self.third_parties[name] = pages
self.third_party_cogs[name] = cog
def remove_third_party(self, cog) -> None:
"""Unregister a cog's dashboard pages."""
name = cog.qualified_name
self.third_parties.pop(name, None)
self.third_party_cogs.pop(name, None)
def get_third_parties_info(self) -> dict:
"""Get info about all registered third parties for the API."""
result = {}
for name, pages in self.third_parties.items():
cog = self.third_party_cogs.get(name)
result[name] = {
"name": name,
"description": getattr(cog, "__doc__", None) or "No description",
"author": getattr(cog, "__author__", "Unknown"),
"pages": {
page_name: {
k: v
for k, v in page_data["params"].items()
}
for page_name, page_data in pages.items()
},
}
return result
async def call_page(
self,
cog_name: str,
page_name: str,
*,
user=None,
guild=None,
method: str = "GET",
data: dict | None = None,
) -> dict:
"""Call a third-party page handler."""
if cog_name not in self.third_parties:
return {"status": 1, "error": "Third party not found"}
pages = self.third_parties[cog_name]
if page_name not in pages:
return {"status": 1, "error": "Page not found"}
page = pages[page_name]
params = page["params"]
if params["is_owner"]:
if user is None or user.id not in self.bot.owner_ids:
return {"status": 1, "error": "Forbidden — owner only"}
if method not in params["methods"]:
return {"status": 1, "error": f"Method {method} not allowed"}
handler = page["func"]
kwargs = {}
sig = inspect.signature(handler)
for param_name in sig.parameters:
if param_name == "self":
continue
if param_name == "user":
kwargs["user"] = user
elif param_name == "guild":
kwargs["guild"] = guild
elif param_name == "method":
kwargs["method"] = method
elif param_name == "data":
kwargs["data"] = data or {}
try:
result = await handler(**kwargs)
return result
except Exception as e:
return {"status": 1, "error": str(e)}

882
dashboard/server.py Normal file
View file

@ -0,0 +1,882 @@
"""
EveDash — aiohttp web server with REST API, WebSocket, and static file serving.
"""
from __future__ import annotations
import asyncio
import datetime
import hashlib
import hmac
import json
import logging
import os
import pathlib
import secrets
import time
import typing
import aiohttp
from aiohttp import web
if typing.TYPE_CHECKING:
from .dashboard import EveDash
log = logging.getLogger("red.evecogs.dashboard.server")
WEB_DIR = pathlib.Path(__file__).parent / "web"
# ── helpers ──────────────────────────────────────────────────────────────
def _json(data: dict, status: int = 200) -> web.Response:
return web.json_response(data, status=status)
def _err(msg: str, status: int = 400) -> web.Response:
return _json({"error": msg}, status=status)
def _make_token(user_id: int, secret: str) -> str:
ts = str(int(time.time()))
payload = f"{user_id}:{ts}"
sig = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
def _verify_token(token: str, secret: str, max_age: int = 86400 * 7) -> int | None:
try:
parts = token.split(":")
if len(parts) != 3:
return None
user_id, ts, sig = int(parts[0]), int(parts[1]), parts[2]
expected = hmac.new(secret.encode(), f"{user_id}:{ts}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
if time.time() - ts > max_age:
return None
return user_id
except Exception:
return None
# ── middleware ────────────────────────────────────────────────────────────
def _build_auth_middleware(cog: EveDash):
@web.middleware
async def auth_middleware(request: web.Request, handler):
request["user_id"] = None
request["is_owner"] = False
# Skip auth for static, login, callback, and health
path = request.path
if (
not path.startswith("/api/")
and not path.startswith("/ws")
):
return await handler(request)
if path in ("/api/auth/login", "/api/auth/callback", "/api/health"):
return await handler(request)
token = None
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
token = request.cookies.get("eve_token")
if token:
secret = await cog.config.secret_key()
user_id = _verify_token(token, secret)
if user_id:
request["user_id"] = user_id
request["is_owner"] = user_id in cog.bot.owner_ids
return await handler(request)
return auth_middleware
def _require_auth(handler):
async def wrapper(request: web.Request):
if request["user_id"] is None:
return _err("Unauthorized", 401)
return await handler(request)
wrapper.__name__ = handler.__name__
return wrapper
def _require_owner(handler):
async def wrapper(request: web.Request):
if request["user_id"] is None:
return _err("Unauthorized", 401)
if not request["is_owner"]:
return _err("Forbidden — bot owner only", 403)
return await handler(request)
wrapper.__name__ = handler.__name__
return wrapper
# ── WebSocket manager ────────────────────────────────────────────────────
class WebSocketManager:
def __init__(self):
self.connections: dict[int, list[web.WebSocketResponse]] = {}
async def add(self, user_id: int, ws: web.WebSocketResponse):
self.connections.setdefault(user_id, []).append(ws)
async def remove(self, user_id: int, ws: web.WebSocketResponse):
if user_id in self.connections:
self.connections[user_id] = [c for c in self.connections[user_id] if c is not ws]
if not self.connections[user_id]:
del self.connections[user_id]
async def broadcast(self, event: str, data: dict, owner_only: bool = False):
msg = json.dumps({"event": event, "data": data})
dead = []
for uid, conns in self.connections.items():
for ws in conns:
try:
await ws.send_str(msg)
except Exception:
dead.append((uid, ws))
for uid, ws in dead:
await self.remove(uid, ws)
async def send_to(self, user_id: int, event: str, data: dict):
msg = json.dumps({"event": event, "data": data})
for ws in self.connections.get(user_id, []):
try:
await ws.send_str(msg)
except Exception:
pass
# ── Server class ─────────────────────────────────────────────────────────
class DashboardServer:
def __init__(self, cog: EveDash):
self.cog = cog
self.bot = cog.bot
self.app: web.Application | None = None
self.runner: web.AppRunner | None = None
self.site: web.TCPSite | None = None
self.ws_manager = WebSocketManager()
self._start_time = time.time()
self._command_count = 0
self._message_count = 0
async def start(self, host: str, port: int):
self.app = web.Application(middlewares=[_build_auth_middleware(self.cog)])
self._register_routes()
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, host, port)
await self.site.start()
log.info(f"EveDash server started on http://{host}:{port}")
async def stop(self):
if self.runner:
await self.runner.cleanup()
log.info("EveDash server stopped")
def _register_routes(self):
r = self.app.router
# Health
r.add_get("/api/health", self._health)
# Auth
r.add_get("/api/auth/login", self._auth_login)
r.add_get("/api/auth/callback", self._auth_callback)
r.add_get("/api/auth/me", _require_auth(self._auth_me))
r.add_post("/api/auth/logout", self._auth_logout)
# Bot
r.add_get("/api/bot/info", _require_auth(self._bot_info))
r.add_get("/api/bot/stats", _require_auth(self._bot_stats))
# Guilds
r.add_get("/api/guilds", _require_auth(self._guilds_list))
r.add_get("/api/guilds/{guild_id}", _require_auth(self._guild_detail))
r.add_get("/api/guilds/{guild_id}/channels", _require_auth(self._guild_channels))
r.add_get("/api/guilds/{guild_id}/roles", _require_auth(self._guild_roles))
# Guild settings
r.add_get("/api/guilds/{guild_id}/settings", _require_auth(self._guild_settings_get))
r.add_put("/api/guilds/{guild_id}/settings", _require_auth(self._guild_settings_update))
# Commands
r.add_get("/api/guilds/{guild_id}/commands", _require_auth(self._guild_commands))
r.add_put("/api/guilds/{guild_id}/commands/{command}", _require_auth(self._guild_command_toggle))
# Cog management (owner only)
r.add_get("/api/cogs", _require_owner(self._cogs_list))
r.add_post("/api/cogs/{cog_name}/load", _require_owner(self._cog_load))
r.add_post("/api/cogs/{cog_name}/unload", _require_owner(self._cog_unload))
r.add_post("/api/cogs/{cog_name}/reload", _require_owner(self._cog_reload))
# Third-party
r.add_get("/api/guilds/{guild_id}/third-parties", _require_auth(self._third_parties_list))
r.add_get("/api/guilds/{guild_id}/third-parties/{cog_name}/{page}", _require_auth(self._third_party_page))
r.add_post("/api/guilds/{guild_id}/third-parties/{cog_name}/{page}", _require_auth(self._third_party_page))
# Admin
r.add_get("/api/admin/config", _require_owner(self._admin_config))
r.add_put("/api/admin/config", _require_owner(self._admin_config_update))
r.add_get("/api/admin/blacklist", _require_owner(self._admin_blacklist))
r.add_post("/api/admin/blacklist", _require_owner(self._admin_blacklist_add))
r.add_delete("/api/admin/blacklist/{user_id}", _require_owner(self._admin_blacklist_remove))
r.add_get("/api/admin/whitelist", _require_owner(self._admin_whitelist))
# WebSocket
r.add_get("/ws", self._ws_handler)
# Static files (SPA)
r.add_get("/", self._serve_index)
r.add_static("/static/", path=str(WEB_DIR), name="static", show_index=False)
# Catch-all for SPA routing
r.add_get("/{path:.*}", self._serve_spa)
# ── Health ───────────────────────────────────────────────────────────
async def _health(self, request: web.Request) -> web.Response:
return _json({"status": "ok", "uptime": int(time.time() - self._start_time)})
# ── Auth ─────────────────────────────────────────────────────────────
async def _auth_login(self, request: web.Request) -> web.Response:
client_id = await self.cog.config.client_id()
redirect_uri = await self.cog.config.redirect_uri()
if not client_id or not redirect_uri:
return _err("Dashboard not configured. Run [p]evedash setup", 500)
state = secrets.token_urlsafe(32)
url = (
f"https://discord.com/api/oauth2/authorize"
f"?client_id={client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope=identify+guilds"
f"&state={state}"
)
return _json({"url": url, "state": state})
async def _auth_callback(self, request: web.Request) -> web.Response:
code = request.query.get("code")
if not code:
return _err("Missing authorization code")
client_id = await self.cog.config.client_id()
client_secret = await self.cog.config.client_secret()
redirect_uri = await self.cog.config.redirect_uri()
# Exchange code for token
async with aiohttp.ClientSession() as session:
resp = await session.post(
"https://discord.com/api/oauth2/token",
data={
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status != 200:
return _err("Failed to exchange authorization code")
token_data = await resp.json()
# Get user info
resp2 = await session.get(
"https://discord.com/api/users/@me",
headers={"Authorization": f"Bearer {token_data['access_token']}"},
)
if resp2.status != 200:
return _err("Failed to fetch user info")
user_data = await resp2.json()
user_id = int(user_data["id"])
# Check access: must be bot owner or share a guild
is_owner = user_id in self.bot.owner_ids
has_guild = any(g.get_member(user_id) for g in self.bot.guilds)
if not is_owner and not has_guild:
return _err("You don't share any servers with this bot", 403)
# Check blacklist
blacklist = await self.cog.config.blacklist()
if user_id in blacklist:
return _err("You are blacklisted from the dashboard", 403)
# Generate token
secret = await self.cog.config.secret_key()
token = _make_token(user_id, secret)
# Redirect to frontend with token
base_url = redirect_uri.rsplit("/", 2)[0] # Remove /api/auth/callback
resp = web.HTTPFound(f"{base_url}/#/callback?token={token}")
resp.set_cookie(
"eve_token", token,
max_age=86400 * 7,
httponly=True,
samesite="Lax",
)
return resp
async def _auth_me(self, request: web.Request) -> web.Response:
user_id = request["user_id"]
user = self.bot.get_user(user_id)
if not user:
# Try to fetch
try:
user = await self.bot.fetch_user(user_id)
except Exception:
return _err("User not found", 404)
return _json({
"id": str(user.id),
"username": user.name,
"display_name": user.display_name,
"avatar": str(user.display_avatar.url) if user.display_avatar else None,
"is_owner": user.id in self.bot.owner_ids,
})
async def _auth_logout(self, request: web.Request) -> web.Response:
resp = _json({"status": "ok"})
resp.del_cookie("eve_token")
return resp
# ── Bot info ─────────────────────────────────────────────────────────
async def _bot_info(self, request: web.Request) -> web.Response:
bot = self.bot
app_info = await bot.application_info()
prefixes = await bot.get_valid_prefixes()
return _json({
"name": bot.user.name,
"id": str(bot.user.id),
"avatar": str(bot.user.display_avatar.url),
"discriminator": bot.user.discriminator,
"guild_count": len(bot.guilds),
"user_count": sum(g.member_count or 0 for g in bot.guilds),
"channel_count": sum(len(g.channels) for g in bot.guilds),
"cog_count": len(bot.cogs),
"command_count": len(set(bot.walk_commands())),
"prefixes": prefixes[:5],
"description": app_info.description or "",
"owner": {
"id": str(app_info.owner.id) if app_info.owner else None,
"name": str(app_info.owner) if app_info.owner else None,
},
"latency_ms": round(bot.latency * 1000, 1),
"uptime_seconds": int(time.time() - self._start_time),
"red_version": getattr(bot, "_red_version", "Unknown"),
})
async def _bot_stats(self, request: web.Request) -> web.Response:
bot = self.bot
guilds_by_size = sorted(bot.guilds, key=lambda g: g.member_count or 0, reverse=True)
top_guilds = [
{"name": g.name, "id": str(g.id), "members": g.member_count or 0, "icon": str(g.icon.url) if g.icon else None}
for g in guilds_by_size[:10]
]
# Channel type breakdown
text_channels = sum(len(g.text_channels) for g in bot.guilds)
voice_channels = sum(len(g.voice_channels) for g in bot.guilds)
categories = sum(len(g.categories) for g in bot.guilds)
return _json({
"guilds": len(bot.guilds),
"users": sum(g.member_count or 0 for g in bot.guilds),
"text_channels": text_channels,
"voice_channels": voice_channels,
"categories": categories,
"cogs_loaded": len(bot.cogs),
"commands": len(set(bot.walk_commands())),
"latency_ms": round(bot.latency * 1000, 1),
"uptime_seconds": int(time.time() - self._start_time),
"top_guilds": top_guilds,
"message_count": self._message_count,
"command_count": self._command_count,
})
# ── Guilds ───────────────────────────────────────────────────────────
async def _guilds_list(self, request: web.Request) -> web.Response:
user_id = request["user_id"]
is_owner = request["is_owner"]
query = request.query.get("q", "").lower()
guilds = []
for g in self.bot.guilds:
if not is_owner:
member = g.get_member(user_id)
if not member:
continue
if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator):
continue
if query and query not in g.name.lower():
continue
guilds.append({
"id": str(g.id),
"name": g.name,
"icon": str(g.icon.url) if g.icon else None,
"member_count": g.member_count or 0,
"channel_count": len(g.channels),
"role_count": len(g.roles),
"owner_id": str(g.owner_id),
})
guilds.sort(key=lambda x: x["member_count"], reverse=True)
return _json({"guilds": guilds})
async def _guild_detail(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
return _json({
"id": str(guild.id),
"name": guild.name,
"icon": str(guild.icon.url) if guild.icon else None,
"banner": str(guild.banner.url) if guild.banner else None,
"member_count": guild.member_count or 0,
"channel_count": len(guild.channels),
"text_channels": len(guild.text_channels),
"voice_channels": len(guild.voice_channels),
"role_count": len(guild.roles),
"emoji_count": len(guild.emojis),
"boost_level": guild.premium_tier,
"boost_count": guild.premium_subscription_count or 0,
"owner": {"id": str(guild.owner_id), "name": str(guild.owner) if guild.owner else "Unknown"},
"created_at": guild.created_at.isoformat(),
"features": list(guild.features),
})
async def _guild_channels(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
channels = []
for c in sorted(guild.channels, key=lambda x: (x.position, x.name)):
channels.append({
"id": str(c.id),
"name": c.name,
"type": str(c.type),
"position": c.position,
"category": str(c.category_id) if c.category_id else None,
})
return _json({"channels": channels})
async def _guild_roles(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
roles = []
for r in sorted(guild.roles, key=lambda x: x.position, reverse=True):
roles.append({
"id": str(r.id),
"name": r.name,
"color": str(r.color),
"position": r.position,
"mentionable": r.mentionable,
"hoist": r.hoist,
"managed": r.managed,
"member_count": len(r.members),
})
return _json({"roles": roles})
# ── Guild settings ───────────────────────────────────────────────────
async def _guild_settings_get(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
prefixes = await self.bot.get_valid_prefixes(guild)
# Get admin/mod roles from Red's config
admin_role_ids = await self.bot._config.guild(guild).admin_role()
mod_role_ids = await self.bot._config.guild(guild).mod_role()
# Ignored status
ignored = await self.bot._config.guild(guild).ignored()
# Disabled commands
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
# Bot nickname
bot_member = guild.get_member(self.bot.user.id)
bot_nick = bot_member.nick if bot_member else None
return _json({
"prefixes": prefixes,
"admin_roles": [str(r) for r in admin_role_ids],
"mod_roles": [str(r) for r in mod_role_ids],
"ignored": ignored,
"disabled_commands": list(disabled_cmds),
"bot_nickname": bot_nick,
})
async def _guild_settings_update(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
data = await request.json()
if "prefixes" in data:
prefixes = data["prefixes"]
if isinstance(prefixes, list) and prefixes:
await self.bot._config.guild(guild).prefix.set(prefixes)
if "admin_roles" in data:
role_ids = [int(r) for r in data["admin_roles"] if r]
await self.bot._config.guild(guild).admin_role.set(role_ids)
if "mod_roles" in data:
role_ids = [int(r) for r in data["mod_roles"] if r]
await self.bot._config.guild(guild).mod_role.set(role_ids)
if "bot_nickname" in data:
bot_member = guild.get_member(self.bot.user.id)
if bot_member:
try:
await bot_member.edit(nick=data["bot_nickname"] or None)
except Exception:
pass
return _json({"status": "ok"})
# ── Commands ─────────────────────────────────────────────────────────
async def _guild_commands(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
cogs_data = {}
for cmd in sorted(self.bot.walk_commands(), key=lambda c: c.qualified_name):
cog_name = cmd.cog_name or "No Category"
if cog_name not in cogs_data:
cog = cmd.cog
cogs_data[cog_name] = {
"name": cog_name,
"description": (cog.__doc__ or "").strip().split("\n")[0] if cog else "",
"commands": [],
}
cogs_data[cog_name]["commands"].append({
"name": cmd.qualified_name,
"description": cmd.short_doc or "",
"enabled": cmd.qualified_name not in disabled_cmds,
"hidden": cmd.hidden,
"aliases": list(cmd.aliases),
"signature": cmd.signature,
"parent": cmd.parent.qualified_name if cmd.parent else None,
})
return _json({"cogs": cogs_data})
async def _guild_command_toggle(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
cmd_name = request.match_info["command"]
data = await request.json()
enabled = data.get("enabled", True)
async with self.bot._config.guild(guild).disabled_commands() as disabled:
if enabled and cmd_name in disabled:
disabled.remove(cmd_name)
elif not enabled and cmd_name not in disabled:
disabled.append(cmd_name)
return _json({"status": "ok", "command": cmd_name, "enabled": enabled})
# ── Cog management ───────────────────────────────────────────────────
async def _cogs_list(self, request: web.Request) -> web.Response:
loaded = {}
for name, cog in self.bot.cogs.items():
loaded[name] = {
"name": name,
"loaded": True,
"description": (cog.__doc__ or "").strip().split("\n")[0],
"author": getattr(cog, "__author__", "Unknown"),
"commands": [c.qualified_name for c in cog.get_commands()],
}
# Get available cogs from downloader if available
available = []
downloader = self.bot.get_cog("Downloader")
if downloader:
try:
installed = await downloader.installed_cogs()
for inst_cog in installed:
cog_name = inst_cog.name
if cog_name not in loaded:
available.append({
"name": cog_name,
"loaded": False,
"description": "",
"repo": str(getattr(inst_cog, "repo_name", "unknown")),
})
except Exception:
pass
return _json({"loaded": loaded, "available": available})
async def _cog_load(self, request: web.Request) -> web.Response:
cog_name = request.match_info["cog_name"]
try:
await self.bot.load_extension(f"cogs.{cog_name}")
return _json({"status": "ok", "message": f"{cog_name} loaded"})
except Exception as e:
# Try Red's cog loading
try:
spec = await self.bot._cog_mgr.find_cog(cog_name)
if spec:
await self.bot.load_extension(spec)
return _json({"status": "ok", "message": f"{cog_name} loaded"})
except Exception as e2:
return _err(f"Failed to load {cog_name}: {e2}")
return _err(f"Failed to load {cog_name}: {e}")
async def _cog_unload(self, request: web.Request) -> web.Response:
cog_name = request.match_info["cog_name"]
if cog_name.lower() == "dashboard":
return _err("Cannot unload the Dashboard cog from the dashboard")
try:
await self.bot.remove_cog(cog_name)
return _json({"status": "ok", "message": f"{cog_name} unloaded"})
except Exception as e:
return _err(f"Failed to unload {cog_name}: {e}")
async def _cog_reload(self, request: web.Request) -> web.Response:
cog_name = request.match_info["cog_name"]
try:
cog = self.bot.get_cog(cog_name)
if cog:
module = cog.__module__
await self.bot.remove_cog(cog_name)
await self.bot.load_extension(module)
return _json({"status": "ok", "message": f"{cog_name} reloaded"})
return _err(f"Cog {cog_name} not found")
except Exception as e:
return _err(f"Failed to reload {cog_name}: {e}")
# ── Third-party integration ──────────────────────────────────────────
async def _third_parties_list(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
tp_info = self.cog.third_parties.get_third_parties_info()
return _json({"third_parties": tp_info})
async def _third_party_page(self, request: web.Request) -> web.Response:
guild = self._get_guild(request)
if not guild:
return _err("Guild not found", 404)
if not self._check_guild_access(request, guild):
return _err("Forbidden", 403)
cog_name = request.match_info["cog_name"]
page_name = request.match_info["page"]
user = self.bot.get_user(request["user_id"])
data = None
if request.method == "POST":
data = await request.json()
result = await self.cog.third_parties.call_page(
cog_name, page_name,
user=user, guild=guild,
method=request.method, data=data,
)
return _json(result)
# ── Admin ────────────────────────────────────────────────────────────
async def _admin_config(self, request: web.Request) -> web.Response:
prefixes = await self.bot._config.prefix()
return _json({
"global_prefixes": prefixes,
"owner_ids": [str(uid) for uid in self.bot.owner_ids],
"embeds": await self.bot._config.embeds(),
"color": await self.bot._config.color(),
"fuzzy": await self.bot._config.fuzzy(),
"invite_public": await self.bot._config.invite_public(),
"invite_perm": await self.bot._config.invite_perm(),
"disabled_commands": list(await self.bot._config.disabled_commands()),
"locale": await self.bot._config.locale(),
})
async def _admin_config_update(self, request: web.Request) -> web.Response:
data = await request.json()
if "global_prefixes" in data:
prefixes = data["global_prefixes"]
if isinstance(prefixes, list) and prefixes:
await self.bot._config.prefix.set(prefixes)
if "embeds" in data:
await self.bot._config.embeds.set(bool(data["embeds"]))
if "fuzzy" in data:
await self.bot._config.fuzzy.set(bool(data["fuzzy"]))
if "invite_public" in data:
await self.bot._config.invite_public.set(bool(data["invite_public"]))
if "locale" in data:
await self.bot._config.locale.set(data["locale"])
return _json({"status": "ok"})
async def _admin_blacklist(self, request: web.Request) -> web.Response:
blacklist = await self.cog.config.blacklist()
users = []
for uid in blacklist:
user = self.bot.get_user(uid)
users.append({
"id": str(uid),
"name": str(user) if user else f"Unknown#{uid}",
"avatar": str(user.display_avatar.url) if user and user.display_avatar else None,
})
return _json({"blacklist": users})
async def _admin_blacklist_add(self, request: web.Request) -> web.Response:
data = await request.json()
user_id = int(data.get("user_id", 0))
if not user_id:
return _err("Invalid user ID")
async with self.cog.config.blacklist() as bl:
if user_id not in bl:
bl.append(user_id)
return _json({"status": "ok"})
async def _admin_blacklist_remove(self, request: web.Request) -> web.Response:
user_id = int(request.match_info["user_id"])
async with self.cog.config.blacklist() as bl:
if user_id in bl:
bl.remove(user_id)
return _json({"status": "ok"})
async def _admin_whitelist(self, request: web.Request) -> web.Response:
# Red's global whitelist
try:
whitelist = await self.bot._config.whitelist()
users = []
for uid in whitelist:
user = self.bot.get_user(uid)
users.append({
"id": str(uid),
"name": str(user) if user else f"Unknown#{uid}",
})
return _json({"whitelist": users})
except Exception:
return _json({"whitelist": []})
# ── WebSocket ────────────────────────────────────────────────────────
async def _ws_handler(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
# Authenticate via first message
user_id = request["user_id"]
if not user_id:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=10)
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
secret = await self.cog.config.secret_key()
user_id = _verify_token(data.get("token", ""), secret)
except Exception:
pass
if not user_id:
await ws.close(message=b"Unauthorized")
return ws
await self.ws_manager.add(user_id, ws)
await ws.send_json({"event": "connected", "data": {"user_id": str(user_id)}})
try:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
if data.get("type") == "ping":
await ws.send_json({"event": "pong", "data": {}})
elif msg.type == aiohttp.WSMsgType.ERROR:
break
finally:
await self.ws_manager.remove(user_id, ws)
return ws
# ── Static serving ───────────────────────────────────────────────────
async def _serve_index(self, request: web.Request) -> web.Response:
index_path = WEB_DIR / "index.html"
if index_path.exists():
return web.FileResponse(index_path)
return _err("Frontend not found", 500)
async def _serve_spa(self, request: web.Request) -> web.Response:
path = request.match_info.get("path", "")
file_path = WEB_DIR / path
# Serve actual files if they exist
if file_path.is_file():
return web.FileResponse(file_path)
# Otherwise serve index.html (SPA routing)
index_path = WEB_DIR / "index.html"
if index_path.exists():
return web.FileResponse(index_path)
return _err("Not found", 404)
# ── Helpers ──────────────────────────────────────────────────────────
def _get_guild(self, request: web.Request):
try:
guild_id = int(request.match_info["guild_id"])
return self.bot.get_guild(guild_id)
except (KeyError, ValueError):
return None
def _check_guild_access(self, request: web.Request, guild) -> bool:
if request["is_owner"]:
return True
user_id = request["user_id"]
member = guild.get_member(user_id)
if not member:
return False
return member.guild_permissions.manage_guild or member.guild_permissions.administrator

1116
dashboard/web/app.js Normal file

File diff suppressed because it is too large Load diff

30
dashboard/web/index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EveDash — Bot Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app">
<!-- Loading screen -->
<div id="loading-screen" class="loading-screen">
<div class="loading-content">
<div class="loading-logo">
<i class="fas fa-robot"></i>
</div>
<div class="loading-spinner"></div>
<p>Loading EveDash...</p>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

984
dashboard/web/style.css Normal file
View file

@ -0,0 +1,984 @@
/* ═══════════════════════════════════════════════════════════════════════
EveDash — Discord-inspired dark theme dashboard
═══════════════════════════════════════════════════════════════════════ */
:root {
/* Core palette */
--bg-primary: #0b0d15;
--bg-secondary: #111320;
--bg-tertiary: #171a2c;
--bg-card: #1a1d30;
--bg-card-hover: #1f2238;
--bg-input: #12142280;
--bg-overlay: rgba(0, 0, 0, 0.6);
/* Accent */
--accent: #5865F2;
--accent-hover: #4752c4;
--accent-soft: rgba(88, 101, 242, 0.12);
--accent-glow: rgba(88, 101, 242, 0.25);
/* Semantic */
--success: #57F287;
--success-soft: rgba(87, 242, 135, 0.12);
--warning: #FEE75C;
--warning-soft: rgba(254, 231, 92, 0.12);
--danger: #ED4245;
--danger-soft: rgba(237, 66, 69, 0.12);
--info: #5bc0eb;
--info-soft: rgba(91, 192, 235, 0.12);
/* Text */
--text-primary: #f2f3f5;
--text-secondary: #8b8fa3;
--text-muted: #5c5f73;
--text-link: #00b0f4;
/* Border */
--border: #25283d;
--border-hover: #353853;
/* Sidebar */
--sidebar-width: 260px;
--sidebar-collapsed: 72px;
/* Misc */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset ──────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
font-size: 15px;
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: var(--text-link); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; height: auto; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea {
font-family: inherit;
font-size: inherit;
color: var(--text-primary);
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
/* ── Loading Screen ─────────────────────────────────────────────────── */
.loading-screen {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-primary);
z-index: 10000;
transition: opacity 0.4s ease, visibility 0.4s ease;
}
.loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.loading-content { text-align: center; }
.loading-logo {
font-size: 3rem;
color: var(--accent);
margin-bottom: 1.5rem;
animation: pulse 2s ease-in-out infinite;
}
.loading-spinner {
width: 36px; height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
.loading-content p { color: var(--text-secondary); font-size: 0.9rem; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
/* ── Layout ─────────────────────────────────────────────────────────── */
.layout {
display: flex;
min-height: 100vh;
}
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
transition: margin-left var(--transition);
}
.main-content.sidebar-collapsed { margin-left: var(--sidebar-collapsed); }
.page-header {
padding: 2rem 2.5rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(12px);
}
.page-header h1 {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.page-header p { color: var(--text-secondary); font-size: 0.9rem; }
.page-body { padding: 2rem 2.5rem; }
/* ── Sidebar ────────────────────────────────────────────────────────── */
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0;
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
z-index: 100;
transition: width var(--transition);
overflow: hidden;
}
.sidebar.collapsed { width: var(--sidebar-collapsed); }
.sidebar-brand {
padding: 1.25rem 1.25rem 1rem;
display: flex; align-items: center; gap: 0.75rem;
border-bottom: 1px solid var(--border);
min-height: 64px;
}
.sidebar-brand .brand-icon {
width: 38px; height: 38px;
border-radius: var(--radius-md);
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; color: #fff;
flex-shrink: 0;
}
.sidebar-brand .brand-text {
font-size: 1.05rem; font-weight: 700;
white-space: nowrap;
}
.sidebar-brand .brand-text span { color: var(--accent); }
.sidebar.collapsed .brand-text,
.sidebar.collapsed .nav-label,
.sidebar.collapsed .nav-text,
.sidebar.collapsed .guild-selector-text,
.sidebar.collapsed .nav-section-title,
.sidebar.collapsed .sidebar-footer-info { display: none; }
.sidebar-toggle {
position: absolute; top: 18px; right: -14px;
width: 28px; height: 28px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem;
z-index: 101;
transition: all var(--transition);
}
.sidebar-toggle:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Guild Selector */
.guild-selector {
margin: 1rem;
padding: 0.65rem 0.85rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
display: flex; align-items: center; gap: 0.65rem;
cursor: pointer;
transition: all var(--transition);
position: relative;
}
.guild-selector:hover { border-color: var(--accent); background: var(--bg-card); }
.guild-selector .guild-icon {
width: 32px; height: 32px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.guild-selector .guild-icon-placeholder {
width: 32px; height: 32px;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem; font-weight: 600;
flex-shrink: 0;
}
.guild-selector-text {
flex: 1; min-width: 0;
}
.guild-selector-text .guild-name {
font-size: 0.85rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.guild-selector-text .guild-members {
font-size: 0.7rem; color: var(--text-muted);
}
.guild-selector .chevron {
color: var(--text-muted); font-size: 0.7rem;
}
/* Guild dropdown */
.guild-dropdown {
position: absolute;
left: 0; top: calc(100% + 6px);
width: 100%;
max-height: 300px;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 200;
display: none;
padding: 0.4rem;
}
.guild-dropdown.open { display: block; }
.guild-dropdown-search {
width: 100%;
padding: 0.5rem 0.65rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.8rem;
margin-bottom: 0.4rem;
outline: none;
}
.guild-dropdown-search:focus { border-color: var(--accent); }
.guild-dropdown-item {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
}
.guild-dropdown-item:hover { background: var(--accent-soft); }
.guild-dropdown-item.active { background: var(--accent-soft); }
.guild-dropdown-item img {
width: 28px; height: 28px; border-radius: var(--radius-sm); object-fit: cover;
}
.guild-dropdown-item .placeholder-icon {
width: 28px; height: 28px; border-radius: var(--radius-sm);
background: var(--accent-soft); color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 600;
}
.guild-dropdown-item span {
font-size: 0.82rem; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Nav */
.sidebar-nav {
flex: 1; overflow-y: auto;
padding: 0.5rem 0.75rem;
}
.nav-section-title {
font-size: 0.65rem; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 0.75rem 0.5rem 0.35rem;
}
.nav-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.87rem; font-weight: 500;
cursor: pointer;
transition: all var(--transition);
margin-bottom: 2px;
white-space: nowrap;
}
.nav-item i { width: 18px; text-align: center; font-size: 0.9rem; flex-shrink: 0; }
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.nav-item.active {
background: var(--accent-soft);
color: var(--accent);
}
.nav-item.active i { color: var(--accent); }
.nav-badge {
margin-left: auto;
font-size: 0.65rem;
padding: 0.1rem 0.45rem;
border-radius: 10px;
font-weight: 600;
background: var(--accent-soft);
color: var(--accent);
}
/* Sidebar footer */
.sidebar-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 0.65rem;
}
.sidebar-footer .user-avatar {
width: 32px; height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.sidebar-footer-info {
flex: 1; min-width: 0;
}
.sidebar-footer-info .user-name {
font-size: 0.82rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sidebar-footer-info .user-role {
font-size: 0.7rem; color: var(--text-muted);
}
.sidebar-footer .logout-btn {
background: none; border: none;
color: var(--text-muted); font-size: 0.9rem;
padding: 0.3rem;
transition: color var(--transition);
}
.sidebar-footer .logout-btn:hover { color: var(--danger); }
/* ── Login Page ─────────────────────────────────────────────────────── */
.login-page {
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
.login-bg {
position: absolute; inset: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(88, 101, 242, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(87, 242, 135, 0.05) 0%, transparent 50%),
radial-gradient(ellipse at 50% 80%, rgba(237, 66, 69, 0.04) 0%, transparent 50%);
}
.login-card {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 3rem 2.5rem;
width: 420px; max-width: 90vw;
text-align: center;
box-shadow: var(--shadow-lg);
}
.login-card .logo { font-size: 3rem; color: var(--accent); margin-bottom: 1rem; }
.login-card h1 { font-size: 1.7rem; font-weight: 800; margin-bottom: 0.4rem; }
.login-card h1 span { color: var(--accent); }
.login-card p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 2rem; }
.login-btn {
display: inline-flex; align-items: center; gap: 0.6rem;
padding: 0.8rem 2rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius-md);
font-size: 1rem; font-weight: 600;
transition: all var(--transition);
}
.login-btn:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 15px var(--accent-glow); }
.login-btn:active { transform: translateY(0); }
.login-btn i { font-size: 1.2rem; }
.login-features {
margin-top: 2rem;
display: grid; grid-template-columns: 1fr 1fr;
gap: 0.75rem;
text-align: left;
}
.login-feature {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.78rem; color: var(--text-secondary);
}
.login-feature i { color: var(--success); font-size: 0.7rem; }
/* ── Cards ──────────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all var(--transition);
}
.card:hover { border-color: var(--border-hover); }
.card-header {
padding: 1.1rem 1.3rem;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.card-header h3 {
font-size: 0.95rem; font-weight: 600;
display: flex; align-items: center; gap: 0.5rem;
}
.card-header h3 i { color: var(--accent); font-size: 0.85rem; }
.card-body { padding: 1.3rem; }
/* Stat cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all var(--transition);
position: relative;
overflow: hidden;
}
.stat-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
.stat-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 3px;
background: var(--accent);
opacity: 0;
transition: opacity var(--transition);
}
.stat-card:hover::before { opacity: 1; }
.stat-card .stat-icon {
width: 40px; height: 40px;
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
margin-bottom: 0.85rem;
}
.stat-card .stat-icon.blue { background: var(--accent-soft); color: var(--accent); }
.stat-card .stat-icon.green { background: var(--success-soft); color: var(--success); }
.stat-card .stat-icon.yellow { background: var(--warning-soft); color: var(--warning); }
.stat-card .stat-icon.red { background: var(--danger-soft); color: var(--danger); }
.stat-card .stat-icon.cyan { background: var(--info-soft); color: var(--info); }
.stat-card .stat-value { font-size: 1.7rem; font-weight: 800; line-height: 1.2; }
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-secondary); margin-top: 0.15rem; }
/* ── Tables ─────────────────────────────────────────────────────────── */
.table-container { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
td {
padding: 0.8rem 1rem;
font-size: 0.87rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255, 0.02); }
/* ── Forms & Inputs ─────────────────────────────────────────────────── */
.form-group { margin-bottom: 1.1rem; }
.form-label {
display: block;
font-size: 0.82rem; font-weight: 600;
margin-bottom: 0.4rem;
}
.form-hint {
font-size: 0.73rem; color: var(--text-muted);
margin-top: 0.25rem;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.6rem 0.85rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.87rem;
outline: none;
transition: border-color var(--transition);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus { border-color: var(--accent); }
.form-input::placeholder,
.form-textarea::placeholder { color: var(--text-muted); }
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b8fa3'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.8rem center;
padding-right: 2.2rem;
}
.form-textarea { min-height: 80px; resize: vertical; }
/* Toggle */
.toggle-wrapper {
display: flex; align-items: center; gap: 0.75rem;
}
.toggle {
position: relative;
width: 42px; height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider {
position: absolute; inset: 0;
background: var(--border);
border-radius: 12px;
cursor: pointer;
transition: background var(--transition);
}
.toggle .slider::before {
content: '';
position: absolute;
left: 3px; top: 3px;
width: 18px; height: 18px;
border-radius: 50%;
background: #fff;
transition: transform var(--transition);
}
.toggle input:checked + .slider { background: var(--accent); }
.toggle input:checked + .slider::before { transform: translateX(18px); }
/* ── Buttons ────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.55rem 1.1rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 0.82rem; font-weight: 600;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); color: #000; }
.btn-success:hover { filter: brightness(0.9); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(0.85); }
.btn-ghost {
background: transparent; color: var(--text-secondary);
border-color: var(--border);
}
.btn-ghost:hover { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-hover); }
.btn-sm { padding: 0.35rem 0.75rem; font-size: 0.78rem; }
.btn-icon { padding: 0.5rem; width: 34px; height: 34px; justify-content: center; }
/* ── Badges / Tags ──────────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 10px;
font-size: 0.7rem; font-weight: 600;
white-space: nowrap;
}
.badge-success { background: var(--success-soft); color: var(--success); }
.badge-danger { background: var(--danger-soft); color: var(--danger); }
.badge-warning { background: var(--warning-soft); color: var(--warning); }
.badge-info { background: var(--info-soft); color: var(--info); }
.badge-primary { background: var(--accent-soft); color: var(--accent); }
.badge-muted { background: rgba(255,255,255,0.06); color: var(--text-muted); }
/* ── Toasts ─────────────────────────────────────────────────────────── */
.toast-container {
position: fixed; top: 1.5rem; right: 1.5rem;
z-index: 10000;
display: flex; flex-direction: column; gap: 0.5rem;
}
.toast {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.75rem 1.1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 0.85rem; font-weight: 500;
min-width: 280px; max-width: 400px;
animation: slideInRight 0.3s ease;
transition: all 0.3s ease;
}
.toast.removing { opacity: 0; transform: translateX(100%); }
.toast.success { border-left: 3px solid var(--success); }
.toast.success i { color: var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.error i { color: var(--danger); }
.toast.warning { border-left: 3px solid var(--warning); }
.toast.warning i { color: var(--warning); }
.toast.info { border-left: 3px solid var(--info); }
.toast.info i { color: var(--info); }
.toast .toast-close {
margin-left: auto;
background: none; border: none;
color: var(--text-muted); cursor: pointer;
padding: 0.2rem;
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
/* ── Search ─────────────────────────────────────────────────────────── */
.search-box {
position: relative;
max-width: 300px;
}
.search-box i {
position: absolute; left: 0.75rem; top: 50%;
transform: translateY(-50%);
color: var(--text-muted); font-size: 0.85rem;
}
.search-box input {
width: 100%;
padding: 0.55rem 0.75rem 0.55rem 2.2rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
transition: border-color var(--transition);
}
.search-box input:focus { border-color: var(--accent); }
/* ── Cog cards ──────────────────────────────────────────────────────── */
.cog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.cog-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.2rem;
transition: all var(--transition);
}
.cog-card:hover { border-color: var(--accent); }
.cog-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.6rem;
}
.cog-card-header h4 {
font-size: 0.95rem; font-weight: 600;
display: flex; align-items: center; gap: 0.5rem;
}
.cog-card p { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.75rem; line-height: 1.5; }
.cog-card-actions { display: flex; gap: 0.4rem; }
/* ── Command rows ───────────────────────────────────────────────────── */
.command-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0.65rem 0.85rem;
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.command-row:hover { background: var(--bg-tertiary); }
.command-info {
flex: 1; min-width: 0;
}
.command-name {
font-size: 0.87rem; font-weight: 600;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.command-desc { font-size: 0.78rem; color: var(--text-secondary); margin-top: 0.1rem; }
.command-aliases {
display: flex; gap: 0.3rem; margin-top: 0.25rem;
}
.command-aliases span {
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
background: rgba(255,255,255,0.04);
border-radius: 4px;
color: var(--text-muted);
}
/* ── Accordion / collapsible cog sections ───────────────────────────── */
.accordion-item {
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: 0.6rem;
overflow: hidden;
}
.accordion-header {
padding: 0.85rem 1rem;
background: var(--bg-card);
cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
user-select: none;
transition: background var(--transition);
}
.accordion-header:hover { background: var(--bg-card-hover); }
.accordion-header h4 {
font-size: 0.9rem; font-weight: 600;
display: flex; align-items: center; gap: 0.5rem;
}
.accordion-header .arrow {
transition: transform var(--transition);
color: var(--text-muted);
font-size: 0.75rem;
}
.accordion-item.open .accordion-header .arrow { transform: rotate(180deg); }
.accordion-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease;
}
.accordion-item.open .accordion-body { max-height: 2000px; }
.accordion-body-inner { padding: 0.5rem 0.75rem 1rem; }
/* ── Third-party settings auto-form ─────────────────────────────────── */
.settings-form { max-width: 600px; }
.settings-form .form-group {
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
}
.settings-form .form-group:last-child { border-bottom: none; }
/* ── Empty state ────────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-state i {
font-size: 2.5rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.1rem; font-weight: 600;
margin-bottom: 0.4rem;
}
.empty-state p {
font-size: 0.85rem; color: var(--text-secondary);
max-width: 400px; margin: 0 auto;
}
/* ── Chart container ────────────────────────────────────────────────── */
.chart-container {
position: relative;
height: 200px;
width: 100%;
}
/* ── Tab bar ────────────────────────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.tab-item {
padding: 0.65rem 1.1rem;
font-size: 0.85rem; font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--transition);
}
.tab-item:hover { color: var(--text-primary); }
.tab-item.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ── Status indicator ───────────────────────────────────────────────── */
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.offline { background: var(--danger); }
/* ── Guild header hero ──────────────────────────────────────────────── */
.guild-hero {
display: flex; align-items: center; gap: 1.25rem;
padding: 1.5rem 0;
}
.guild-hero-icon {
width: 72px; height: 72px;
border-radius: var(--radius-lg);
object-fit: cover;
border: 3px solid var(--border);
}
.guild-hero-icon-placeholder {
width: 72px; height: 72px;
border-radius: var(--radius-lg);
background: var(--accent-soft);
color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 1.8rem; font-weight: 700;
border: 3px solid var(--border);
}
.guild-hero-info h2 { font-size: 1.4rem; font-weight: 700; }
.guild-hero-info .guild-meta {
display: flex; gap: 1.25rem; margin-top: 0.3rem;
}
.guild-hero-info .guild-meta span {
font-size: 0.8rem; color: var(--text-secondary);
display: flex; align-items: center; gap: 0.35rem;
}
.guild-hero-info .guild-meta i { font-size: 0.75rem; }
/* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); width: var(--sidebar-width); }
.sidebar.mobile-open { transform: translateX(0); }
.main-content { margin-left: 0 !important; }
.page-header, .page-body { padding: 1rem 1.25rem; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.login-features { grid-template-columns: 1fr; }
.mobile-menu-btn { display: flex !important; }
}
.mobile-menu-btn {
display: none !important;
position: fixed; top: 1rem; left: 1rem;
width: 40px; height: 40px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
align-items: center; justify-content: center;
z-index: 99;
cursor: pointer;
}
/* ── Misc utilities ─────────────────────────────────────────────────── */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 0.5rem; }
.gap-2 { gap: 1rem; }
.gap-3 { gap: 1.5rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.text-muted { color: var(--text-secondary); }
.text-sm { font-size: 0.82rem; }
.text-xs { font-size: 0.73rem; }
.font-mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.w-full { width: 100%; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }

View file

@ -3,6 +3,6 @@
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
"name": "EveCogs",
"short": "A collection of advanced cogs for Red-DiscordBot.",
"description": "EveCogs is a premium cog repository featuring Wormhole v3.3.0 — the ultimate cross-server relay system with 70+ commands, DM relay, analytics, polls, bridging, auto-mod, and more.",
"description": "EveCogs is a premium cog repository featuring Wormhole v3.3.0 (ultimate cross-server relay), NewHelpMenu (Components v2 help), and EveDash (self-contained web dashboard with cog SDK).",
"type": "REPO"
}