diff --git a/app.py b/app.py
index 3b742f0..0e54a88 100644
--- a/app.py
+++ b/app.py
@@ -67,9 +67,12 @@ from config import (
# In-process state
# ---------------------------------------------------------------------------
-# sid → { username, ip, is_admin, joined_at, user_id, is_registered,
+# sid → { username, ip, is_admin, role, joined_at, user_id, is_registered,
# has_ai_access, ai_messages_used }
connected_users: dict = {}
+
+# role hierarchy – higher number = more power
+ROLE_POWER = {"user": 0, "mod": 1, "admin": 2, "root": 3}
username_to_sid: dict = {} # lowercase_name → sid
muted_users: set = set()
banned_usernames: set = set()
@@ -276,6 +279,7 @@ def _get_nicklist() -> list:
"is_admin": info["is_admin"],
"is_registered": info.get("is_registered", False),
"is_verified": info.get("is_verified", False),
+ "role": info.get("role", "user"),
})
# Static "Violet" AI user
users.append({
@@ -283,7 +287,8 @@ def _get_nicklist() -> list:
"is_admin": False,
"is_registered": True,
"is_verified": True,
- "is_ai": True
+ "is_ai": True,
+ "role": "user",
})
return sorted(users, key=lambda u: u["username"].lower())
@@ -299,6 +304,23 @@ def _require_admin(f):
return wrapped
+def _require_role(min_role):
+ """Decorator: require at least min_role power level."""
+ def decorator(f):
+ @functools.wraps(f)
+ def wrapped(*args, **kwargs):
+ user = connected_users.get(request.sid)
+ if not user:
+ emit("error", {"msg": "Forbidden."}); return
+ user_power = ROLE_POWER.get(user.get("role", "user"), 0)
+ needed = ROLE_POWER.get(min_role, 0)
+ if user_power < needed:
+ emit("error", {"msg": "Forbidden."}); return
+ return f(*args, **kwargs)
+ return wrapped
+ return decorator
+
+
def _rate_limited(sid: str) -> bool:
now = time.time()
message_timestamps[sid] = [t for t in message_timestamps[sid] if now - t < RATE_WINDOW]
@@ -425,6 +447,7 @@ def on_connect(auth=None):
"username": None,
"ip": ip,
"is_admin": False,
+ "role": "user",
"joined_at": time.time(),
"user_id": user_id,
"is_registered": is_registered,
@@ -519,20 +542,31 @@ def on_join(data):
if lower in username_to_sid and username_to_sid[lower] != sid:
emit("error", {"msg": "Username already in use."}); return
- is_admin = False
- mod_pw = str(data.get("mod_password", "")).strip()
- if mod_pw and mod_pw == ADMIN_PASSWORD:
- is_admin = True
+ # Derive role from DB (root/admin/mod grant is_admin automatically)
+ db_role = db_user.role if db_user else "user"
+ is_admin = ROLE_POWER.get(db_role, 0) >= ROLE_POWER["mod"]
- user["username"] = username
- user["is_admin"] = is_admin
- user["is_verified"] = db_user.is_verified if db_user else True # Guests are always "verified" for lobby
+ # Legacy mod-password fallback for guests (temporary mod access)
+ mod_pw = str(data.get("mod_password", "")).strip()
+ if mod_pw and mod_pw == ADMIN_PASSWORD and not is_admin:
+ is_admin = True
+ if db_role == "user":
+ db_role = "mod" # temporary elevation for the session
+
+ user["username"] = username
+ user["is_admin"] = is_admin
+ user["role"] = db_role
+ user["is_verified"] = db_user.is_verified if db_user else True
username_to_sid[lower] = sid
join_room(LOBBY)
+ # Role badge for join message
+ role_icon = {"root": "👑 ", "admin": "⚔️ ", "mod": "🛡️ "}.get(db_role, "")
+
emit("joined", {
"username": username,
"is_admin": is_admin,
+ "role": db_role,
"is_registered": user["is_registered"],
"has_ai_access": user["has_ai_access"],
"ai_messages_used": user["ai_messages_used"],
@@ -541,7 +575,7 @@ def on_join(data):
"ignored_list": [u.username for u in db_user.ignoring] if db_user else []
})
emit("system", {
- "msg": f"{'🛡️ ' if is_admin else ''}**{username}** joined the room.",
+ "msg": f"{role_icon}**{username}** joined the room.",
"ts": _ts(),
}, to=LOBBY)
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
@@ -908,3 +942,178 @@ def on_change_password(data):
db_user.password_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
db.session.commit()
emit("password_changed", {"success": True})
+
+
+# ---------------------------------------------------------------------------
+# Admin panel
+# ---------------------------------------------------------------------------
+
+@socketio.on("admin_get_users")
+@_require_role("mod")
+def on_admin_get_users(_data=None):
+ """Send the full user list to the admin panel."""
+ users = User.query.order_by(User.id).all()
+ result = []
+ for u in users:
+ if u.username == AI_BOT_NAME:
+ continue
+ online_sid = username_to_sid.get(u.username.lower())
+ result.append({
+ "id": u.id,
+ "username": u.username,
+ "role": u.role,
+ "is_verified": u.is_verified,
+ "has_ai_access": u.has_ai_access,
+ "email": u.email or "",
+ "created_at": u.created_at.strftime("%Y-%m-%d"),
+ "online": online_sid is not None,
+ })
+ emit("admin_users", {"users": result})
+
+
+@socketio.on("admin_get_bans")
+@_require_role("mod")
+def on_admin_get_bans(_data=None):
+ bans = Ban.query.order_by(Ban.created_at.desc()).all()
+ emit("admin_bans", {"bans": [
+ {"id": b.id, "username": b.username, "ip": b.ip or "", "reason": b.reason or "",
+ "created_at": b.created_at.strftime("%Y-%m-%d")} for b in bans
+ ]})
+
+
+@socketio.on("admin_get_mutes")
+@_require_role("mod")
+def on_admin_get_mutes(_data=None):
+ mutes = Mute.query.order_by(Mute.created_at.desc()).all()
+ emit("admin_mutes", {"mutes": [
+ {"id": m.id, "username": m.username,
+ "created_at": m.created_at.strftime("%Y-%m-%d")} for m in mutes
+ ]})
+
+
+@socketio.on("admin_set_role")
+@_require_role("admin")
+def on_admin_set_role(data):
+ """Change a user's role. Only root can set admin/root. Admins can set mod/user."""
+ sid = request.sid
+ me = connected_users.get(sid)
+ my_power = ROLE_POWER.get(me.get("role", "user"), 0)
+
+ target_id = int(data.get("user_id", 0))
+ new_role = str(data.get("role", "")).strip().lower()
+ if new_role not in ROLE_POWER:
+ emit("error", {"msg": "Invalid role."}); return
+
+ target_power = ROLE_POWER[new_role]
+ if target_power >= my_power:
+ emit("error", {"msg": "Cannot assign a role equal/above your own."}); return
+
+ target_user = db.session.get(User, target_id)
+ if not target_user:
+ emit("error", {"msg": "User not found."}); return
+
+ # Can't change someone with equal or higher power
+ if ROLE_POWER.get(target_user.role, 0) >= my_power:
+ emit("error", {"msg": "Cannot modify a user with equal/higher privileges."}); return
+
+ target_user.role = new_role
+ db.session.commit()
+
+ # Update live session if they're online
+ target_sid = username_to_sid.get(target_user.username.lower())
+ if target_sid and target_sid in connected_users:
+ connected_users[target_sid]["role"] = new_role
+ connected_users[target_sid]["is_admin"] = ROLE_POWER[new_role] >= ROLE_POWER["mod"]
+ # Notify the target user of their new role
+ socketio.emit("role_updated", {"role": new_role}, to=target_sid)
+
+ socketio.emit("system", {
+ "msg": f"⚙️ **{target_user.username}** is now **{new_role}**.",
+ "ts": _ts()
+ }, to=LOBBY)
+ socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
+ emit("admin_action_ok", {"msg": f"{target_user.username} → {new_role}"})
+
+
+@socketio.on("admin_verify_user")
+@_require_role("mod")
+def on_admin_verify(data):
+ target_id = int(data.get("user_id", 0))
+ target_user = db.session.get(User, target_id)
+ if not target_user:
+ emit("error", {"msg": "User not found."}); return
+
+ target_user.is_verified = not target_user.is_verified
+ db.session.commit()
+
+ status = "verified" if target_user.is_verified else "unverified"
+
+ target_sid = username_to_sid.get(target_user.username.lower())
+ if target_sid and target_sid in connected_users:
+ connected_users[target_sid]["is_verified"] = target_user.is_verified
+
+ socketio.emit("system", {
+ "msg": f"{'✅' if target_user.is_verified else '❌'} **{target_user.username}** was {status}.",
+ "ts": _ts()
+ }, to=LOBBY)
+ socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
+ emit("admin_action_ok", {"msg": f"{target_user.username} → {status}"})
+
+
+@socketio.on("admin_toggle_ai")
+@_require_role("admin")
+def on_admin_toggle_ai(data):
+ target_id = int(data.get("user_id", 0))
+ target_user = db.session.get(User, target_id)
+ if not target_user:
+ emit("error", {"msg": "User not found."}); return
+
+ target_user.has_ai_access = not target_user.has_ai_access
+ db.session.commit()
+
+ target_sid = username_to_sid.get(target_user.username.lower())
+ if target_sid and target_sid in connected_users:
+ connected_users[target_sid]["has_ai_access"] = target_user.has_ai_access
+
+ status = "granted" if target_user.has_ai_access else "revoked"
+ emit("admin_action_ok", {"msg": f"AI access {status} for {target_user.username}"})
+
+
+@socketio.on("admin_unban")
+@_require_role("mod")
+def on_admin_unban(data):
+ ban_id = int(data.get("ban_id", 0))
+ ban = db.session.get(Ban, ban_id)
+ if not ban:
+ emit("error", {"msg": "Ban not found."}); return
+
+ banned_usernames.discard(ban.username.lower())
+ if ban.ip:
+ banned_ips.discard(ban.ip)
+ db.session.delete(ban)
+ db.session.commit()
+
+ socketio.emit("system", {
+ "msg": f"🔓 **{ban.username}** was unbanned.",
+ "ts": _ts()
+ }, to=LOBBY)
+ emit("admin_action_ok", {"msg": f"Unbanned {ban.username}"})
+
+
+@socketio.on("admin_unmute")
+@_require_role("mod")
+def on_admin_unmute(data):
+ mute_id = int(data.get("mute_id", 0))
+ mute = db.session.get(Mute, mute_id)
+ if not mute:
+ emit("error", {"msg": "Mute not found."}); return
+
+ muted_users.discard(mute.username.lower())
+ db.session.delete(mute)
+ db.session.commit()
+
+ socketio.emit("system", {
+ "msg": f"🔊 **{mute.username}** was unmuted.",
+ "ts": _ts()
+ }, to=LOBBY)
+ emit("admin_action_ok", {"msg": f"Unmuted {mute.username}"})
diff --git a/index.html b/index.html
index 31fea73..3e9d408 100644
--- a/index.html
+++ b/index.html
@@ -89,6 +89,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/models.py b/models.py
index 44e429e..8ddc408 100644
--- a/models.py
+++ b/models.py
@@ -22,6 +22,7 @@ class User(db.Model):
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(255), unique=True, nullable=True)
+ role = db.Column(db.String(10), default="user", nullable=False) # root, admin, mod, user
has_ai_access = db.Column(db.Boolean, default=False, nullable=False)
ai_messages_used = db.Column(db.Integer, default=0, nullable=False)
is_verified = db.Column(db.Boolean, default=False, nullable=False)
diff --git a/static/chat.js b/static/chat.js
index 0a15a65..09b01e1 100644
--- a/static/chat.js
+++ b/static/chat.js
@@ -19,6 +19,7 @@ const socket = io({
const state = {
username: null,
isAdmin: false,
+ role: "user",
isRegistered: false,
hasAiAccess: false,
aiMessagesUsed: 0,
@@ -146,6 +147,7 @@ window.addEventListener("DOMContentLoaded", () => {
socket.on("joined", (data) => {
state.username = data.username;
state.isAdmin = data.is_admin;
+ state.role = data.role || "user";
state.isRegistered = data.is_registered;
state.hasAiAccess = data.has_ai_access;
state.aiMessagesUsed = data.ai_messages_used;
@@ -158,6 +160,10 @@ socket.on("joined", (data) => {
joinScreen.classList.add("hidden");
chatScreen.classList.remove("hidden");
updateVioletBadge();
+
+ // Show admin panel button for mods+
+ const adminBtn = $("admin-btn");
+ if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
});
socket.on("error", (data) => {
@@ -522,10 +528,11 @@ function renderNicklist() {
const li = document.createElement("li");
const isIgnored = state.ignoredUsers.has(u.username);
const isUnverified = u.is_registered && !u.is_verified;
+ const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
li.innerHTML = `
- ${u.is_admin ? '★ ' : ''}
+ ${roleIcon ? `${roleIcon} ` : ''}
${u.username}
${u.is_registered ? '✔' : ''}
${isIgnored ? ' (ignored)' : ''}
@@ -600,6 +607,9 @@ function executeMenuAction(action, target) {
case "kick":
socket.emit("mod_kick", { target });
break;
+ case "mute":
+ socket.emit("mod_mute", { target });
+ break;
case "ban":
socket.emit("mod_ban", { target });
break;
@@ -818,3 +828,182 @@ function escapeHTML(str) {
p.textContent = str;
return p.innerHTML;
}
+
+// ── Role updated (live notification) ──────────────────────────────────────
+
+socket.on("role_updated", (data) => {
+ state.role = data.role;
+ state.isAdmin = ["mod", "admin", "root"].includes(data.role);
+ const adminBtn = $("admin-btn");
+ if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
+});
+
+// ── Admin Panel ───────────────────────────────────────────────────────────
+
+const adminModal = $("admin-modal");
+const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 };
+
+if ($("admin-btn")) {
+ $("admin-btn").onclick = () => {
+ adminModal.classList.remove("hidden");
+ socket.emit("admin_get_users");
+ socket.emit("admin_get_bans");
+ socket.emit("admin_get_mutes");
+ };
+}
+
+$("close-admin").onclick = () => adminModal.classList.add("hidden");
+adminModal.addEventListener("click", (e) => {
+ if (e.target === adminModal) adminModal.classList.add("hidden");
+});
+
+// Admin tab switching
+document.querySelectorAll(".admin-tab").forEach(tab => {
+ tab.addEventListener("click", () => {
+ document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active"));
+ document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active"));
+ tab.classList.add("active");
+ $("atab-" + tab.dataset.atab).classList.add("active");
+ });
+});
+
+// Admin toast helper
+function adminToast(msg) {
+ const t = $("admin-toast");
+ t.textContent = msg;
+ t.classList.remove("hidden");
+ setTimeout(() => t.classList.add("hidden"), 2500);
+}
+
+socket.on("admin_action_ok", (data) => adminToast(data.msg));
+
+// ── Users pane ───────────────────────────────────────────────────────────
+
+let adminUserCache = [];
+
+socket.on("admin_users", (data) => {
+ adminUserCache = data.users;
+ renderAdminUsers(adminUserCache);
+});
+
+$("admin-user-search").addEventListener("input", (e) => {
+ const q = e.target.value.toLowerCase();
+ renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q)));
+});
+
+function renderAdminUsers(users) {
+ const list = $("admin-user-list");
+ list.innerHTML = "";
+ const myPower = ROLE_POWER[state.role] || 0;
+
+ users.forEach(u => {
+ const row = document.createElement("div");
+ row.className = "admin-user-row";
+
+ const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin;
+ const canVerify = myPower >= ROLE_POWER.mod;
+ const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root";
+
+ // Build role selector
+ let roleHTML = "";
+ if (canEditRole) {
+ const opts = ["user", "mod"];
+ if (myPower >= ROLE_POWER.root) opts.push("admin");
+ roleHTML = ``;
+ } else {
+ roleHTML = `${u.role}`;
+ }
+
+ row.innerHTML = `
+ ${escapeHTML(u.username)}
+ ${roleHTML}
+
+ ${u.online ? 'online' : ''}
+ ${u.is_verified ? 'verified' : 'unverified'}
+ ${u.has_ai_access ? 'AI' : ''}
+
+
+ ${canVerify ? `` : ''}
+ ${canToggleAI ? `` : ''}
+
+ `;
+ list.appendChild(row);
+ });
+
+ // Event delegation for actions
+ list.onclick = (e) => {
+ const btn = e.target.closest("[data-action]");
+ if (!btn) return;
+ const uid = parseInt(btn.dataset.uid);
+ const action = btn.dataset.action;
+ if (action === "verify") socket.emit("admin_verify_user", { user_id: uid });
+ else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid });
+ };
+ list.onchange = (e) => {
+ const sel = e.target.closest("[data-action='set-role']");
+ if (!sel) return;
+ const uid = parseInt(sel.dataset.uid);
+ socket.emit("admin_set_role", { user_id: uid, role: sel.value });
+ };
+}
+
+// ── Bans pane ────────────────────────────────────────────────────────────
+
+socket.on("admin_bans", (data) => {
+ const list = $("admin-ban-list");
+ const empty = $("admin-no-bans");
+ list.innerHTML = "";
+ empty.classList.toggle("hidden", data.bans.length > 0);
+
+ data.bans.forEach(b => {
+ const row = document.createElement("div");
+ row.className = "admin-ban-row";
+ row.innerHTML = `
+
+ ${escapeHTML(b.username)}
+ ${b.ip ? `${b.ip}` : ''}
+ ${b.created_at}
+
+
+ `;
+ list.appendChild(row);
+ });
+
+ list.onclick = (e) => {
+ const btn = e.target.closest("[data-bid]");
+ if (!btn) return;
+ socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) });
+ btn.closest(".admin-ban-row").remove();
+ };
+});
+
+// ── Mutes pane ───────────────────────────────────────────────────────────
+
+socket.on("admin_mutes", (data) => {
+ const list = $("admin-mute-list");
+ const empty = $("admin-no-mutes");
+ list.innerHTML = "";
+ empty.classList.toggle("hidden", data.mutes.length > 0);
+
+ data.mutes.forEach(m => {
+ const row = document.createElement("div");
+ row.className = "admin-mute-row";
+ row.innerHTML = `
+
+ ${escapeHTML(m.username)}
+ ${m.created_at}
+
+
+ `;
+ list.appendChild(row);
+ });
+
+ list.onclick = (e) => {
+ const btn = e.target.closest("[data-mid]");
+ if (!btn) return;
+ socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) });
+ btn.closest(".admin-mute-row").remove();
+ };
+});
diff --git a/static/style.css b/static/style.css
index 113e7d0..36f8a0e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -970,6 +970,189 @@ textarea {
/* ── Mobile Overrides ─────────────────────────────────────────────────────── */
+/* ── Admin Panel ──────────────────────────────────────────────────────────── */
+.admin-card {
+ width: 100%;
+ max-width: 640px;
+ max-height: 85vh;
+ border-radius: 24px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.admin-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem 2rem 0;
+}
+
+.admin-header h2 { font-size: 1.4rem; font-weight: 700; }
+
+.admin-tabs {
+ display: flex;
+ margin: 1rem 2rem 0;
+ background: rgba(0, 0, 0, 0.3);
+ padding: 3px;
+ border-radius: 10px;
+ gap: 2px;
+}
+
+.admin-tab {
+ flex: 1;
+ padding: 7px 4px;
+ border: none;
+ background: transparent;
+ color: var(--text-dim);
+ font-weight: 600;
+ font-size: 0.8rem;
+ cursor: pointer;
+ border-radius: 8px;
+ transition: all 0.2s;
+}
+
+.admin-tab.active {
+ background: var(--accent-purple);
+ color: white;
+ box-shadow: 0 2px 8px var(--accent-glow);
+}
+
+.admin-body {
+ padding: 1rem 2rem 1.5rem;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.admin-pane { display: none; }
+.admin-pane.active { display: block; }
+
+.admin-search-wrap { margin-bottom: 0.75rem; }
+
+.admin-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 50vh;
+ overflow-y: auto;
+}
+
+.admin-user-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid var(--glass-border);
+ border-radius: 12px;
+ font-size: 0.85rem;
+ flex-wrap: wrap;
+}
+
+.admin-user-row .au-name {
+ font-weight: 700;
+ color: var(--text-main);
+ min-width: 90px;
+}
+
+.admin-user-row .au-role {
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.au-role.root { background: rgba(255, 215, 0, 0.2); color: #ffd700; }
+.au-role.admin { background: rgba(255, 0, 255, 0.15); color: var(--accent-magenta); }
+.au-role.mod { background: rgba(0, 200, 83, 0.15); color: #00c853; }
+.au-role.user { background: rgba(255, 255, 255, 0.08); color: var(--text-dim); }
+
+.admin-user-row .au-badges {
+ display: flex;
+ gap: 4px;
+ flex: 1;
+}
+
+.au-badge {
+ font-size: 0.65rem;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-weight: 600;
+}
+
+.au-badge.online { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
+.au-badge.verified { background: rgba(0, 200, 255, 0.1); color: var(--ai-teal); }
+.au-badge.unverified { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
+.au-badge.ai-on { background: rgba(138, 43, 226, 0.15); color: var(--accent-purple); }
+
+.admin-user-row .au-actions {
+ display: flex;
+ gap: 4px;
+ margin-left: auto;
+}
+
+.au-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--glass-border);
+ background: transparent;
+ color: var(--text-dim);
+ border-radius: 6px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.au-btn:hover { background: var(--accent-purple); color: white; border-color: var(--accent-purple); }
+.au-btn.danger:hover { background: var(--error-red); border-color: var(--error-red); }
+
+.au-select {
+ padding: 3px 6px;
+ background: rgba(0, 0, 0, 0.4);
+ border: 1px solid var(--glass-border);
+ color: var(--text-main);
+ border-radius: 6px;
+ font-size: 0.7rem;
+ cursor: pointer;
+ outline: none;
+}
+
+.admin-ban-row, .admin-mute-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid var(--glass-border);
+ border-radius: 12px;
+ font-size: 0.85rem;
+}
+
+.admin-ban-row .ab-name, .admin-mute-row .am-name {
+ font-weight: 700;
+ color: var(--error-red);
+}
+
+.admin-empty {
+ text-align: center;
+ color: var(--text-dim);
+ font-size: 0.85rem;
+ padding: 2rem 0;
+}
+
+.admin-toast {
+ margin: 0 2rem 1rem;
+ padding: 8px 12px;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ background: rgba(0, 255, 170, 0.15);
+ color: var(--success-green);
+ text-align: center;
+ transition: opacity 0.3s;
+}
+
/* ── Theme Picker ─────────────────────────────────────────────────────────── */
.theme-grid {
display: grid;