aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js369
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css203
3 files changed, 616 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 2179e99..d36a0a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -113,7 +113,8 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, route, menuOpen }) {
+function Sidebar({ groups, route, menuOpen, role }) {
+ const isStaff = role === 'moderator' || role === 'admin';
return html`
<aside class="sidebar ${menuOpen ? 'open' : ''}">
<div class="sidebar-section">
@@ -135,6 +136,10 @@ function Sidebar({ groups, route, menuOpen }) {
href="#/explore">${t('sidebar.public_groups')}</a>
<a class="sidebar-item ${route === '/settings' ? 'active' : ''}"
href="#/settings">${t('sidebar.settings')}</a>
+ ${isStaff && html`
+ <a class="sidebar-item ${route === '/admin' ? 'active' : ''}"
+ href="#/admin">${t('sidebar.admin')}</a>
+ `}
</div>
</aside>
`;
@@ -994,6 +999,338 @@ function SettingsPage({ user, theme, onThemeChange }) {
`;
}
+// ── Admin Panel ─────────────────────────────────────────────────────────────
+
+function AdminPage({ token }) {
+ const [tab, setTab] = useState('stats');
+ const [stats, setStats] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [usersTotal, setUsersTotal] = useState(0);
+ const [userSearch, setUserSearch] = useState('');
+ const [groups, setGroups] = useState([]);
+ const [groupsTotal, setGroupsTotal] = useState(0);
+ const [logs, setLogs] = useState([]);
+ const [logEvent, setLogEvent] = useState('');
+ const [logOffset, setLogOffset] = useState(0);
+ const [blocklist, setBlocklist] = useState([]);
+ const [detailUser, setDetailUser] = useState(null);
+ const [error, setError] = useState('');
+
+ const headers = { Authorization: `Bearer ${token}` };
+
+ const loadStats = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/stats', { token });
+ setStats(data);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadUsers = useCallback(async (q = '') => {
+ try {
+ const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token });
+ setUsers(data.users);
+ setUsersTotal(data.total);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadGroups = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/groups?limit=100', { token });
+ setGroups(data.groups);
+ setGroupsTotal(data.total);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadLogs = useCallback(async (event = '', offset = 0, append = false) => {
+ try {
+ let url = `/v1/admin/logs?limit=50&offset=${offset}`;
+ if (event) url += `&event=${encodeURIComponent(event)}`;
+ const data = await hubFetch(url, { token });
+ setLogs(prev => append ? [...prev, ...data.logs] : data.logs);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadBlocklist = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/blocklist', { token });
+ setBlocklist(data.entries);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ useEffect(() => {
+ setError('');
+ if (tab === 'stats') loadStats();
+ else if (tab === 'users') loadUsers(userSearch);
+ else if (tab === 'groups') loadGroups();
+ else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); }
+ else if (tab === 'blocklist') loadBlocklist();
+ }, [tab]);
+
+ const patchUser = useCallback(async (userId, patch) => {
+ try {
+ await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token });
+ loadUsers(userSearch);
+ if (detailUser && detailUser.id === userId) setDetailUser(null);
+ } catch (e) { setError(e.message); }
+ }, [token, userSearch, detailUser]);
+
+ const patchGroup = useCallback(async (groupId, patch) => {
+ try {
+ await hubFetch(`/v1/admin/groups/${groupId}`, { method: 'PATCH', body: patch, token });
+ loadGroups();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const showUserDetail = useCallback(async (userId) => {
+ try {
+ const data = await hubFetch(`/v1/admin/users/${userId}`, { token });
+ setDetailUser(data);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const addToBlocklist = useCallback(async (hash, reason) => {
+ try {
+ await hubFetch('/v1/admin/blocklist', { method: 'POST', body: { content_hash: hash, reason }, token });
+ loadBlocklist();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const removeFromBlocklist = useCallback(async (hash) => {
+ try {
+ await hubFetch(`/v1/admin/blocklist/${hash}`, { method: 'DELETE', token });
+ loadBlocklist();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const TABS = ['stats', 'users', 'groups', 'logs', 'blocklist'];
+
+ return html`
+ <div>
+ <h2>${t('admin.title')}</h2>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+
+ <div class="admin-tabs">
+ ${TABS.map(k => html`
+ <button key=${k} class="admin-tab ${tab === k ? 'active' : ''}"
+ onClick=${() => setTab(k)}>${t('admin.tab_' + k)}</button>
+ `)}
+ </div>
+
+ ${tab === 'stats' && stats && html`
+ <div class="admin-stats">
+ ${[['users', 'stat_users'], ['groups', 'stat_groups'],
+ ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html`
+ <div class="stat-card" key=${k}>
+ <div class="stat-value">${stats[k]}</div>
+ <div class="stat-label">${t('admin.' + label)}</div>
+ </div>
+ `)}
+ </div>
+ `}
+
+ ${tab === 'users' && html`
+ <div class="admin-toolbar">
+ <input class="admin-search" type="text" placeholder="${t('admin.users_search')}"
+ value=${userSearch} onInput=${e => { setUserSearch(e.target.value); loadUsers(e.target.value); }} />
+ <span class="settings-value">${usersTotal} total</span>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('admin.col_role')}</th>
+ <th>${t('admin.col_status')}</th>
+ <th>${t('admin.col_created')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${users.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_users')}</td></tr>`}
+ ${users.map(u => html`
+ <tr key=${u.id}>
+ <td>${u.username}</td>
+ <td>
+ <select class="admin-role-select" value=${u.role}
+ onChange=${e => patchUser(u.id, { role: e.target.value })}>
+ <option value="user">user</option>
+ <option value="moderator">moderator</option>
+ <option value="admin">admin</option>
+ </select>
+ </td>
+ <td><span class="badge">${u.status}</span></td>
+ <td>${new Date(u.created_at).toLocaleDateString()}</td>
+ <td class="admin-actions">
+ <button class="admin-btn" onClick=${() => showUserDetail(u.id)}>${t('admin.btn_details')}</button>
+ ${u.status === 'active'
+ ? html`<button class="admin-btn danger" onClick=${() => patchUser(u.id, { status: 'suspended' })}>${t('admin.btn_suspend')}</button>`
+ : u.status === 'suspended'
+ ? html`<button class="admin-btn" onClick=${() => patchUser(u.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
+ : null
+ }
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'groups' && html`
+ <div class="admin-toolbar">
+ <span class="settings-value">${groupsTotal} total</span>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_name')}</th>
+ <th>${t('admin.col_visibility')}</th>
+ <th>${t('admin.col_members')}</th>
+ <th>${t('admin.col_status')}</th>
+ <th>${t('admin.col_created')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${groups.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_groups')}</td></tr>`}
+ ${groups.map(g => html`
+ <tr key=${g.id}>
+ <td>${g.name}</td>
+ <td><span class="badge">${g.visibility}</span></td>
+ <td>${g.member_count}</td>
+ <td><span class="badge">${g.status}</span></td>
+ <td>${new Date(g.created_at).toLocaleDateString()}</td>
+ <td class="admin-actions">
+ ${g.status === 'active'
+ ? html`<button class="admin-btn danger" onClick=${() => patchGroup(g.id, { status: 'suspended' })}>${t('admin.btn_suspend')}</button>`
+ : g.status === 'suspended'
+ ? html`<button class="admin-btn" onClick=${() => patchGroup(g.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
+ : null
+ }
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'logs' && html`
+ <div class="admin-toolbar">
+ <select class="admin-select" value=${logEvent} onChange=${e => {
+ setLogEvent(e.target.value);
+ setLogOffset(0);
+ loadLogs(e.target.value, 0);
+ }}>
+ <option value="">${t('admin.filter_all')}</option>
+ ${['login', 'login_fail', 'account_create', 'token_refresh', 'group_create',
+ 'group_join', 'group_leave', 'node_announce', 'revoke_user', 'revoke_group',
+ 'admin_user_update', 'admin_group_update'].map(ev => html`
+ <option key=${ev} value=${ev}>${ev}</option>
+ `)}
+ </select>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_time')}</th>
+ <th>${t('admin.col_event')}</th>
+ <th>${t('admin.col_ip')}</th>
+ <th>${t('admin.col_detail')}</th>
+ </tr></thead>
+ <tbody>
+ ${logs.length === 0 && html`<tr><td colspan="4" class="admin-empty">${t('admin.no_logs')}</td></tr>`}
+ ${logs.map(lg => html`
+ <tr key=${lg.id}>
+ <td style="white-space:nowrap">${new Date(lg.timestamp).toLocaleString()}</td>
+ <td><span class="badge">${lg.event}</span></td>
+ <td>${lg.ip_address}</td>
+ <td>${lg.detail || ''}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${logs.length > 0 && logs.length % 50 === 0 && html`
+ <button class="admin-btn admin-load-more" onClick=${() => {
+ const next = logOffset + 50;
+ setLogOffset(next);
+ loadLogs(logEvent, next, true);
+ }}>${t('admin.btn_load_more')}</button>
+ `}
+ `}
+
+ ${tab === 'blocklist' && html`
+ <${BlocklistForm} onAdd=${addToBlocklist} />
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_hash')}</th>
+ <th>${t('admin.col_reason')}</th>
+ <th>${t('admin.col_date')}</th>
+ <th>${t('admin.col_added_by')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${blocklist.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_blocked')}</td></tr>`}
+ ${blocklist.map(b => html`
+ <tr key=${b.hash}>
+ <td style="font-family:monospace;font-size:0.8em">${b.hash.slice(0, 16)}...</td>
+ <td>${b.reason}</td>
+ <td>${new Date(b.added_at).toLocaleDateString()}</td>
+ <td>${b.added_by || ''}</td>
+ <td>
+ <button class="admin-btn" onClick=${() => removeFromBlocklist(b.hash)}>${t('admin.btn_unblock')}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${detailUser && html`
+ <div class="admin-detail-overlay" onClick=${e => {
+ if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null);
+ }}>
+ <div class="admin-detail-card">
+ <h3>${t('admin.user_detail')}</h3>
+ ${[
+ ['admin.col_username', detailUser.username],
+ ['admin.col_email', detailUser.email],
+ ['admin.col_role', detailUser.role],
+ ['admin.col_status', detailUser.status],
+ ['admin.col_created', new Date(detailUser.created_at).toLocaleString()],
+ ['admin.col_groups', detailUser.group_count],
+ ].map(([label, val]) => html`
+ <div class="admin-detail-row" key=${label}>
+ <span class="admin-detail-label">${t(label)}</span>
+ <span class="admin-detail-value">${val}</span>
+ </div>
+ `)}
+ <button class="admin-btn" style="margin-top:16px;width:100%"
+ onClick=${() => setDetailUser(null)}>${t('admin.btn_close')}</button>
+ </div>
+ </div>
+ `}
+ </div>
+ `;
+}
+
+function BlocklistForm({ onAdd }) {
+ const [hash, setHash] = useState('');
+ const [reason, setReason] = useState('');
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (hash.length === 64 && reason) {
+ onAdd(hash, reason);
+ setHash('');
+ setReason('');
+ }
+ };
+
+ return html`
+ <form class="blocklist-form" onSubmit=${submit}>
+ <input type="text" placeholder="${t('admin.hash_placeholder')}"
+ value=${hash} onInput=${e => setHash(e.target.value)}
+ pattern="[0-9a-f]{64}" required />
+ <input type="text" placeholder="${t('admin.reason_placeholder')}"
+ value=${reason} onInput=${e => setReason(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('admin.btn_block')}</button>
+ </form>
+ `;
+}
+
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
@@ -1026,29 +1363,24 @@ function App() {
const authCtx = {
user,
login: async (username, password) => {
+ let token, refreshToken;
if (window.MeshBayKeys) {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
_sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
- const u = {
- username,
- token: data.accessToken,
- refreshToken: data.refreshToken,
- };
- setUser(u);
- saveAuth(u);
+ token = data.accessToken;
+ refreshToken = data.refreshToken;
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
body: { username, password },
});
- const u = {
- username,
- token: data.access_token,
- refreshToken: data.refresh_token,
- };
- setUser(u);
- saveAuth(u);
+ token = data.access_token;
+ refreshToken = data.refresh_token;
}
+ const me = await hubFetch('/v1/users/me', { token });
+ const u = { username, token, refreshToken, role: me.role };
+ setUser(u);
+ saveAuth(u);
},
logout: () => {
setUser(null);
@@ -1073,6 +1405,10 @@ function App() {
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} />`;
+ } else if (route === '/admin') {
+ page = (user.role === 'moderator' || user.role === 'admin')
+ ? html`<${AdminPage} token=${user.token} />`
+ : html`<${HomePage} groups=${groups} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
onThemeChange=${setTheme} />`;
@@ -1092,7 +1428,8 @@ function App() {
${user && html`<${Sidebar}
groups=${groups}
route=${route}
- menuOpen=${menuOpen} />`}
+ menuOpen=${menuOpen}
+ role=${user.role} />`}
${menuOpen && html`<div class="overlay visible"
onClick=${() => setMenuOpen(false)} />`}
<main class="main">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index e0242cf..cb5133e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -117,6 +117,66 @@ const en = {
// Sidebar
'sidebar.settings': 'Settings',
+ 'sidebar.admin': 'Admin',
+
+ // Admin panel
+ 'admin.title': 'Administration',
+ 'admin.tab_stats': 'Stats',
+ 'admin.tab_users': 'Users',
+ 'admin.tab_groups': 'Groups',
+ 'admin.tab_logs': 'Logs',
+ 'admin.tab_blocklist': 'Blocklist',
+
+ // Admin stats
+ 'admin.stat_users': 'Users',
+ 'admin.stat_groups': 'Groups',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Online Nodes',
+
+ // Admin users
+ 'admin.users_search': 'Search users...',
+ 'admin.col_username': 'Username',
+ 'admin.col_role': 'Role',
+ 'admin.col_status': 'Status',
+ 'admin.col_created': 'Created',
+ 'admin.col_actions': 'Actions',
+ 'admin.col_email': 'Email',
+ 'admin.col_groups': 'Groups',
+ 'admin.no_users': 'No users found',
+ 'admin.btn_suspend': 'Suspend',
+ 'admin.btn_unsuspend': 'Unsuspend',
+ 'admin.btn_details': 'Details',
+ 'admin.user_detail': 'User details',
+ 'admin.btn_close': 'Close',
+ 'admin.self_note': '(you)',
+
+ // Admin groups
+ 'admin.col_name': 'Name',
+ 'admin.col_visibility': 'Visibility',
+ 'admin.col_members': 'Members',
+ 'admin.no_groups': 'No groups found',
+
+ // Admin logs
+ 'admin.col_time': 'Time',
+ 'admin.col_event': 'Event',
+ 'admin.col_user': 'User',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Detail',
+ 'admin.filter_all': 'All events',
+ 'admin.no_logs': 'No logs found',
+ 'admin.btn_load_more': 'Load more',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Hash',
+ 'admin.col_reason': 'Reason',
+ 'admin.col_date': 'Date',
+ 'admin.col_added_by': 'Added by',
+ 'admin.no_blocked': 'No blocked hashes',
+ 'admin.add_hash': 'Add hash to blocklist',
+ 'admin.hash_placeholder': 'blake3 hash (64 hex chars)',
+ 'admin.reason_placeholder': 'Reason',
+ 'admin.btn_block': 'Block',
+ 'admin.btn_unblock': 'Unblock',
};
// ── Locale registry ─────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 27e645e..b1b1a7e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -767,6 +767,209 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.play-btn:hover { background: var(--bg-raised); border-color: var(--success); }
.play-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+/* ── Admin panel ─────────────────────────────────────────────────────────── */
+
+.admin-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 20px;
+ border-bottom: 2px solid var(--border);
+ flex-wrap: wrap;
+}
+
+.admin-tab {
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ color: var(--text-secondary);
+ font-size: 0.9em;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ transition: color 0.15s, border-color 0.15s;
+}
+.admin-tab:hover { color: var(--text); }
+.admin-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 600;
+}
+
+.admin-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.stat-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+ text-align: center;
+}
+.stat-card .stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--accent);
+}
+.stat-card .stat-label {
+ font-size: 0.8em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ margin-top: 4px;
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.admin-search {
+ flex: 1;
+ min-width: 180px;
+ max-width: 300px;
+ padding: 7px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.9em;
+}
+.admin-search:focus { outline: none; border-color: var(--border-focus); }
+
+.admin-select {
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+}
+
+.admin-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.88em;
+}
+.admin-table th {
+ text-align: left;
+ padding: 8px 10px;
+ border-bottom: 2px solid var(--border);
+ color: var(--text-secondary);
+ font-size: 0.8em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+.admin-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+.admin-table tr:hover { background: var(--bg-raised); }
+.admin-table .admin-empty {
+ text-align: center;
+ padding: 24px;
+ color: var(--text-dim);
+ font-style: italic;
+}
+
+.admin-actions { display: flex; gap: 6px; }
+
+.admin-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.82em;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.admin-btn:hover { border-color: var(--accent); color: var(--accent); }
+.admin-btn.danger { color: var(--error); }
+.admin-btn.danger:hover { border-color: var(--error); }
+.admin-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+.admin-role-select {
+ padding: 3px 6px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+}
+
+.admin-detail-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 150;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.admin-detail-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 24px;
+ min-width: 340px;
+ max-width: 90vw;
+ box-shadow: var(--shadow-lg);
+}
+
+.admin-detail-card h3 {
+ margin-bottom: 16px;
+ font-size: 1.1em;
+}
+
+.admin-detail-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 0;
+ font-size: 0.9em;
+}
+.admin-detail-row + .admin-detail-row {
+ border-top: 1px solid var(--border);
+}
+.admin-detail-label { color: var(--text-secondary); }
+.admin-detail-value { font-weight: 500; }
+
+.admin-load-more {
+ display: block;
+ margin: 16px auto;
+ padding: 8px 24px;
+}
+
+.blocklist-form {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+.blocklist-form input {
+ flex: 1;
+ min-width: 180px;
+ padding: 7px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.9em;
+}
+.blocklist-form input:focus { outline: none; border-color: var(--border-focus); }
+
/* ── Overlay (mobile sidebar backdrop) ────────────────────────────────────── */
.overlay {