WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { Hono } from "hono";
2import { BaseLayout } from "../layouts/base.js";
3import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js";
4import {
5 getSessionWithPermissions,
6 hasAnyAdminPermission,
7 canManageMembers,
8 canManageCategories,
9 canViewModLog,
10 canManageRoles,
11 canManageThemes,
12} from "../lib/session.js";
13import { isProgrammingError } from "../lib/errors.js";
14import { logger } from "../lib/logger.js";
15import { createAdminThemeRoutes } from "./admin-themes.js";
16import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js";
17
18// ─── Types ─────────────────────────────────────────────────────────────────
19
20interface MemberEntry {
21 did: string;
22 handle: string;
23 role: string;
24 roleUri: string | null;
25 joinedAt: string | null;
26}
27
28interface RoleEntry {
29 id: string;
30 name: string;
31 uri: string;
32 priority: number;
33}
34
35interface CategoryEntry {
36 id: string;
37 did: string;
38 uri: string;
39 name: string;
40 description: string | null;
41 sortOrder: number | null;
42}
43
44interface BoardEntry {
45 id: string;
46 name: string;
47 description: string | null;
48 sortOrder: number | null;
49 categoryUri: string;
50 uri: string;
51}
52
53interface ModLogEntry {
54 id: string;
55 action: string;
56 moderatorDid: string;
57 moderatorHandle: string;
58 subjectDid: string | null;
59 subjectHandle: string | null;
60 subjectPostUri: string | null;
61 reason: string | null;
62 createdAt: string;
63}
64
65const ACTION_LABELS: Record<string, string> = {
66 "space.atbb.modAction.ban": "Ban",
67 "space.atbb.modAction.unban": "Unban",
68 "space.atbb.modAction.lock": "Lock",
69 "space.atbb.modAction.unlock": "Unlock",
70 "space.atbb.modAction.delete": "Hide",
71 "space.atbb.modAction.undelete": "Unhide",
72};
73
74// ─── Helpers ───────────────────────────────────────────────────────────────
75
76function formatJoinedDate(isoString: string | null): string {
77 if (!isoString) return "—";
78 const d = new Date(isoString);
79 if (isNaN(d.getTime())) return "—";
80 return d.toLocaleDateString("en-US", {
81 month: "short",
82 day: "numeric",
83 year: "numeric",
84 });
85}
86
87function formatModLogDate(isoString: string): string {
88 const d = new Date(isoString);
89 if (isNaN(d.getTime())) return "—";
90 return d.toLocaleString("en-US", {
91 year: "numeric",
92 month: "2-digit",
93 day: "2-digit",
94 hour: "2-digit",
95 minute: "2-digit",
96 hour12: false,
97 });
98}
99
100// ─── Components ────────────────────────────────────────────────────────────
101
102function MemberRow({
103 member,
104 roles,
105 showRoleControls,
106 errorMsg = null,
107}: {
108 member: MemberEntry;
109 roles: RoleEntry[];
110 showRoleControls: boolean;
111 errorMsg?: string | null;
112}) {
113 return (
114 <tr>
115 <td>{member.handle}</td>
116 <td>
117 <span class="role-badge">{member.role}</span>
118 </td>
119 <td>{formatJoinedDate(member.joinedAt)}</td>
120 {showRoleControls ? (
121 <td>
122 <form
123 hx-post={`/admin/members/${member.did}/role`}
124 hx-target="closest tr"
125 hx-swap="outerHTML"
126 >
127 <input type="hidden" name="handle" value={member.handle} />
128 <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} />
129 <input type="hidden" name="currentRole" value={member.role} />
130 <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} />
131 <input type="hidden" name="rolesJson" value={JSON.stringify(roles)} />
132 <div class="member-row__assign-form">
133 <label class="sr-only" for={`role-${member.did}`}>
134 Assign role to {member.handle}
135 </label>
136 <select id={`role-${member.did}`} name="roleUri">
137 {roles.map((role) => (
138 <option value={role.uri} selected={member.roleUri === role.uri}>
139 {role.name}
140 </option>
141 ))}
142 </select>
143 <button type="submit" class="btn btn-primary">
144 Assign
145 </button>
146 </div>
147 {errorMsg && <span class="member-row__error">{errorMsg}</span>}
148 </form>
149 </td>
150 ) : (
151 errorMsg && (
152 <td>
153 <span class="member-row__error">{errorMsg}</span>
154 </td>
155 )
156 )}
157 </tr>
158 );
159}
160
161function ModLogRow({ entry }: { entry: ModLogEntry }) {
162 const label = ACTION_LABELS[entry.action] ?? entry.action;
163 const subject = entry.subjectPostUri
164 ? entry.subjectPostUri
165 : (entry.subjectHandle ?? entry.subjectDid ?? "—");
166
167 return (
168 <tr>
169 <td class="modlog-table__time">{formatModLogDate(entry.createdAt)}</td>
170 <td class="modlog-table__moderator">{entry.moderatorHandle}</td>
171 <td class="modlog-table__action">
172 <span class={`modlog-action-badge modlog-action-badge--${label.toLowerCase()}`}>
173 {label}
174 </span>
175 </td>
176 <td class="modlog-table__subject">{subject}</td>
177 <td class="modlog-table__reason">{entry.reason ?? "—"}</td>
178 </tr>
179 );
180}
181
182// ─── Private Helpers ────────────────────────────────────────────────────────
183
184/**
185 * Extracts the error message from an AppView error response.
186 * Falls back to the provided default if JSON parsing fails.
187 */
188async function extractAppviewError(res: Response, fallback: string): Promise<string> {
189 try {
190 const data = (await res.json()) as { error?: string };
191 return data.error ?? fallback;
192 } catch {
193 return fallback;
194 }
195}
196
197/**
198 * Parses a sort order value from a form field string.
199 * Returns 0 for empty/missing values, null for invalid values (negative or non-integer).
200 */
201function parseSortOrder(value: unknown): number | null {
202 if (typeof value !== "string" || value.trim() === "") return 0;
203 const n = Number(value);
204 return Number.isInteger(n) && n >= 0 ? n : null;
205}
206
207// ─── Routes ────────────────────────────────────────────────────────────────
208
209export function createAdminRoutes(appviewUrl: string) {
210 const app = new Hono<WebAppEnv>();
211
212 // ─── Structure Page Components ──────────────────────────────────────────
213
214 function StructureBoardRow({ board }: { board: BoardEntry }) {
215 const dialogId = `confirm-delete-board-${board.id}`;
216 return (
217 <div class="structure-board">
218 <div class="structure-board__header">
219 <span class="structure-board__name">{board.name}</span>
220 <span class="structure-board__meta">sortOrder: {board.sortOrder ?? 0}</span>
221 <div class="structure-board__actions">
222 <button
223 type="button"
224 class="btn btn-secondary btn-sm"
225 onclick={`document.getElementById('edit-board-${board.id}').open=!document.getElementById('edit-board-${board.id}').open`}
226 >
227 Edit
228 </button>
229 <button
230 type="button"
231 class="btn btn-danger btn-sm"
232 onclick={`document.getElementById('${dialogId}').showModal()`}
233 >
234 Delete
235 </button>
236 </div>
237 </div>
238 <details id={`edit-board-${board.id}`} class="structure-edit-form">
239 <summary class="sr-only">Edit {board.name}</summary>
240 <form method="post" action={`/admin/structure/boards/${board.id}/edit`} class="structure-edit-form__body">
241 <div class="form-group">
242 <label for={`edit-board-name-${board.id}`}>Name</label>
243 <input id={`edit-board-name-${board.id}`} type="text" name="name" value={board.name} required />
244 </div>
245 <div class="form-group">
246 <label for={`edit-board-desc-${board.id}`}>Description</label>
247 <textarea id={`edit-board-desc-${board.id}`} name="description">{board.description ?? ""}</textarea>
248 </div>
249 <div class="form-group">
250 <label for={`edit-board-sort-${board.id}`}>Sort Order</label>
251 <input id={`edit-board-sort-${board.id}`} type="number" name="sortOrder" min="0" value={String(board.sortOrder ?? 0)} />
252 </div>
253 <button type="submit" class="btn btn-primary">Save Changes</button>
254 </form>
255 </details>
256 <dialog id={dialogId} class="structure-confirm-dialog">
257 <p>Delete board "{board.name}"? This cannot be undone.</p>
258 <form method="post" action={`/admin/structure/boards/${board.id}/delete`} class="dialog-actions">
259 <button type="submit" class="btn btn-danger">Delete</button>
260 <button
261 type="button"
262 class="btn btn-secondary"
263 onclick={`document.getElementById('${dialogId}').close()`}
264 >
265 Cancel
266 </button>
267 </form>
268 </dialog>
269 </div>
270 );
271 }
272
273 function StructureCategorySection({
274 category,
275 boards,
276 }: {
277 category: CategoryEntry;
278 boards: BoardEntry[];
279 }) {
280 const dialogId = `confirm-delete-category-${category.id}`;
281 return (
282 <div class="structure-category">
283 <div class="structure-category__header">
284 <span class="structure-category__name">{category.name}</span>
285 <span class="structure-category__meta">sortOrder: {category.sortOrder ?? 0}</span>
286 <div class="structure-category__actions">
287 <button
288 type="button"
289 class="btn btn-secondary btn-sm"
290 onclick={`document.getElementById('edit-category-${category.id}').open=!document.getElementById('edit-category-${category.id}').open`}
291 >
292 Edit
293 </button>
294 <button
295 type="button"
296 class="btn btn-danger btn-sm"
297 onclick={`document.getElementById('${dialogId}').showModal()`}
298 >
299 Delete
300 </button>
301 </div>
302 </div>
303
304 <details id={`edit-category-${category.id}`} class="structure-edit-form">
305 <summary class="sr-only">Edit {category.name}</summary>
306 <form method="post" action={`/admin/structure/categories/${category.id}/edit`} class="structure-edit-form__body">
307 <div class="form-group">
308 <label for={`edit-cat-name-${category.id}`}>Name</label>
309 <input id={`edit-cat-name-${category.id}`} type="text" name="name" value={category.name} required />
310 </div>
311 <div class="form-group">
312 <label for={`edit-cat-desc-${category.id}`}>Description</label>
313 <textarea id={`edit-cat-desc-${category.id}`} name="description">{category.description ?? ""}</textarea>
314 </div>
315 <div class="form-group">
316 <label for={`edit-cat-sort-${category.id}`}>Sort Order</label>
317 <input id={`edit-cat-sort-${category.id}`} type="number" name="sortOrder" min="0" value={String(category.sortOrder ?? 0)} />
318 </div>
319 <button type="submit" class="btn btn-primary">Save Changes</button>
320 </form>
321 </details>
322
323 <dialog id={dialogId} class="structure-confirm-dialog">
324 <p>Delete category "{category.name}"? All boards must be removed first.</p>
325 <form method="post" action={`/admin/structure/categories/${category.id}/delete`} class="dialog-actions">
326 <button type="submit" class="btn btn-danger">Delete</button>
327 <button
328 type="button"
329 class="btn btn-secondary"
330 onclick={`document.getElementById('${dialogId}').close()`}
331 >
332 Cancel
333 </button>
334 </form>
335 </dialog>
336
337 <div class="structure-boards">
338 {boards.map((board) => (
339 <StructureBoardRow board={board} />
340 ))}
341 <details class="structure-add-board">
342 <summary class="structure-add-board__trigger">+ Add Board</summary>
343 <form method="post" action="/admin/structure/boards" class="structure-edit-form__body">
344 <input type="hidden" name="categoryUri" value={category.uri} />
345 <div class="form-group">
346 <label for={`new-board-name-${category.id}`}>Name</label>
347 <input id={`new-board-name-${category.id}`} type="text" name="name" required />
348 </div>
349 <div class="form-group">
350 <label for={`new-board-desc-${category.id}`}>Description</label>
351 <textarea id={`new-board-desc-${category.id}`} name="description"></textarea>
352 </div>
353 <div class="form-group">
354 <label for={`new-board-sort-${category.id}`}>Sort Order</label>
355 <input id={`new-board-sort-${category.id}`} type="number" name="sortOrder" min="0" value="0" />
356 </div>
357 <button type="submit" class="btn btn-primary">Add Board</button>
358 </form>
359 </details>
360 </div>
361 </div>
362 );
363 }
364
365 // ── GET /admin ────────────────────────────────────────────────────────────
366
367 app.get("/admin", async (c) => {
368 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
369 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
370
371 if (!auth.authenticated) {
372 return c.redirect("/login");
373 }
374
375 if (!hasAnyAdminPermission(auth)) {
376 return c.html(
377 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
378 <PageHeader title="Access Denied" />
379 <p>You don't have permission to access the admin panel.</p>
380 </BaseLayout>,
381 403
382 );
383 }
384
385 const showMembers = canManageMembers(auth);
386 const showStructure = canManageCategories(auth);
387 const showModLog = canViewModLog(auth);
388 const showThemes = canManageThemes(auth);
389
390 return c.html(
391 <BaseLayout title="Admin Panel — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
392 <PageHeader title="Admin Panel" />
393 <div class="admin-nav-grid">
394 {showMembers && (
395 <a href="/admin/members" class="admin-nav-card">
396 <Card>
397 <p class="admin-nav-card__icon" aria-hidden="true">👥</p>
398 <p class="admin-nav-card__title">Members</p>
399 <p class="admin-nav-card__description">View and assign member roles</p>
400 </Card>
401 </a>
402 )}
403 {showStructure && (
404 <a href="/admin/structure" class="admin-nav-card">
405 <Card>
406 <p class="admin-nav-card__icon" aria-hidden="true">📁</p>
407 <p class="admin-nav-card__title">Structure</p>
408 <p class="admin-nav-card__description">Manage categories and boards</p>
409 </Card>
410 </a>
411 )}
412 {showModLog && (
413 <a href="/admin/modlog" class="admin-nav-card">
414 <Card>
415 <p class="admin-nav-card__icon" aria-hidden="true">📋</p>
416 <p class="admin-nav-card__title">Mod Log</p>
417 <p class="admin-nav-card__description">Audit trail of moderation actions</p>
418 </Card>
419 </a>
420 )}
421 {showThemes && (
422 <a href="/admin/themes" class="admin-nav-card">
423 <Card>
424 <p class="admin-nav-card__icon" aria-hidden="true">🎨</p>
425 <p class="admin-nav-card__title">Themes</p>
426 <p class="admin-nav-card__description">
427 Customize forum appearance and color schemes
428 </p>
429 </Card>
430 </a>
431 )}
432 </div>
433 </BaseLayout>
434 );
435 });
436
437 // ── GET /admin/members ────────────────────────────────────────────────────
438
439 app.get("/admin/members", async (c) => {
440 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
441 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
442
443 if (!auth.authenticated) {
444 return c.redirect("/login");
445 }
446
447 if (!canManageMembers(auth)) {
448 return c.html(
449 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
450 <PageHeader title="Members" />
451 <p>You don't have permission to manage members.</p>
452 </BaseLayout>,
453 403
454 );
455 }
456
457 const cookie = c.req.header("cookie") ?? "";
458 const showRoleControls = canManageRoles(auth);
459
460 let membersRes: Response;
461 let rolesRes: Response | null = null;
462
463 try {
464 [membersRes, rolesRes] = await Promise.all([
465 fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }),
466 showRoleControls
467 ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } })
468 : Promise.resolve(null),
469 ]);
470 } catch (error) {
471 if (isProgrammingError(error)) throw error;
472 logger.error("Network error fetching members", {
473 operation: "GET /admin/members",
474 error: error instanceof Error ? error.message : String(error),
475 });
476 return c.html(
477 <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
478 <PageHeader title="Members" />
479 <ErrorDisplay
480 message="Unable to load members"
481 detail="The forum is temporarily unavailable. Please try again."
482 />
483 </BaseLayout>,
484 503
485 );
486 }
487
488 if (!membersRes.ok) {
489 if (membersRes.status === 401) {
490 return c.redirect("/login");
491 }
492 logger.error("AppView returned error for members list", {
493 operation: "GET /admin/members",
494 status: membersRes.status,
495 });
496 return c.html(
497 <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
498 <PageHeader title="Members" />
499 <ErrorDisplay
500 message="Something went wrong"
501 detail="Could not load member list. Please try again."
502 />
503 </BaseLayout>,
504 500
505 );
506 }
507
508 const membersData = (await membersRes.json()) as {
509 members: MemberEntry[];
510 isTruncated: boolean;
511 };
512 let rolesData: { roles: RoleEntry[] } | null = null;
513 if (rolesRes?.ok) {
514 try {
515 rolesData = (await rolesRes.json()) as { roles: RoleEntry[] };
516 } catch (error) {
517 if (!(error instanceof SyntaxError)) throw error;
518 logger.error("Malformed JSON from AppView roles response", {
519 operation: "GET /admin/members",
520 });
521 }
522 } else if (rolesRes) {
523 logger.error("AppView returned error for roles list", {
524 operation: "GET /admin/members",
525 status: rolesRes.status,
526 });
527 }
528
529 const members = membersData.members;
530 const roles = rolesData?.roles ?? [];
531 const isTruncated = membersData.isTruncated;
532 const title = `Members (${members.length}${isTruncated ? "+" : ""})`;
533
534 return c.html(
535 <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
536 <PageHeader title={title} />
537 {members.length === 0 ? (
538 <EmptyState message="No members yet" />
539 ) : (
540 <div class="card">
541 <table class="admin-member-table">
542 <thead>
543 <tr>
544 <th scope="col">Handle</th>
545 <th scope="col">Role</th>
546 <th scope="col">Joined</th>
547 {showRoleControls && <th scope="col">Assign Role</th>}
548 </tr>
549 </thead>
550 <tbody>
551 {members.map((member) => (
552 <MemberRow
553 member={member}
554 roles={roles}
555 showRoleControls={showRoleControls}
556 />
557 ))}
558 </tbody>
559 </table>
560 </div>
561 )}
562 </BaseLayout>
563 );
564 });
565
566 // ── POST /admin/members/:did/role (HTMX proxy) ────────────────────────────
567
568 app.post("/admin/members/:did/role", async (c) => {
569 // Permission gate — must come before body parsing
570 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
571 if (!auth.authenticated) {
572 return c.html(
573 <tr>
574 <td colspan={4}>
575 <span class="member-row__error">You must be logged in to perform this action.</span>
576 </td>
577 </tr>,
578 401
579 );
580 }
581 if (!canManageRoles(auth)) {
582 return c.html(
583 <tr>
584 <td colspan={4}>
585 <span class="member-row__error">You don't have permission to assign roles.</span>
586 </td>
587 </tr>,
588 403
589 );
590 }
591
592 const targetDid = c.req.param("did");
593 const cookie = c.req.header("cookie") ?? "";
594
595 let body: Record<string, string | File>;
596 try {
597 body = await c.req.parseBody();
598 } catch (error) {
599 if (isProgrammingError(error)) throw error;
600 logger.error("Failed to parse form body", {
601 operation: "POST /admin/members/:did/role",
602 targetDid,
603 });
604 return c.html(
605 <tr>
606 <td colspan={4}>
607 <span class="member-row__error">Invalid form submission.</span>
608 </td>
609 </tr>
610 );
611 }
612
613 const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : "";
614 const handle = typeof body.handle === "string" ? body.handle : targetDid;
615 const joinedAt = typeof body.joinedAt === "string" && body.joinedAt ? body.joinedAt : null;
616 const currentRole = typeof body.currentRole === "string" ? body.currentRole : "";
617 const currentRoleUri =
618 typeof body.currentRoleUri === "string" && body.currentRoleUri
619 ? body.currentRoleUri
620 : null;
621 const showRoleControls = canManageRoles(auth);
622
623 let roles: RoleEntry[] = [];
624 try {
625 const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]";
626 roles = JSON.parse(rolesJson) as RoleEntry[];
627 } catch (error) {
628 if (!(error instanceof SyntaxError)) throw error;
629 logger.warn("Malformed rolesJson in POST body", {
630 operation: "POST /admin/members/:did/role",
631 targetDid,
632 });
633 return c.html(
634 <MemberRow
635 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
636 roles={[]}
637 showRoleControls={canManageRoles(auth)}
638 errorMsg="Role data was corrupted. Please reload the page."
639 />
640 );
641 }
642
643 if (!roleUri) {
644 return c.html(
645 <MemberRow
646 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
647 roles={roles}
648 showRoleControls={showRoleControls}
649 errorMsg="Please select a role."
650 />
651 );
652 }
653
654 if (!targetDid.startsWith("did:")) {
655 return c.html(
656 <MemberRow
657 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
658 roles={roles}
659 showRoleControls={showRoleControls}
660 errorMsg="Invalid member identifier."
661 />
662 );
663 }
664
665 let appviewRes: Response;
666 try {
667 appviewRes = await fetch(`${appviewUrl}/api/admin/members/${targetDid}/role`, {
668 method: "POST",
669 headers: {
670 "Content-Type": "application/json",
671 Cookie: cookie,
672 },
673 body: JSON.stringify({ roleUri }),
674 });
675 } catch (error) {
676 if (isProgrammingError(error)) throw error;
677 logger.error("Network error proxying role assignment", {
678 operation: "POST /admin/members/:did/role",
679 targetDid,
680 error: error instanceof Error ? error.message : String(error),
681 });
682 return c.html(
683 <MemberRow
684 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
685 roles={roles}
686 showRoleControls={showRoleControls}
687 errorMsg="Forum temporarily unavailable. Please try again."
688 />
689 );
690 }
691
692 if (appviewRes.ok) {
693 let data: { roleAssigned: string; targetDid: string };
694 try {
695 data = (await appviewRes.json()) as { roleAssigned: string; targetDid: string };
696 } catch (error) {
697 if (!(error instanceof SyntaxError)) throw error;
698 logger.error("Malformed JSON from AppView role assignment response", {
699 operation: "POST /admin/members/:did/role",
700 targetDid,
701 });
702 return c.html(
703 <MemberRow
704 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
705 roles={roles}
706 showRoleControls={showRoleControls}
707 errorMsg="Something went wrong. Please try again."
708 />
709 );
710 }
711 const newRoleName = data.roleAssigned || currentRole;
712 return c.html(
713 <MemberRow
714 member={{ did: targetDid, handle, role: newRoleName, roleUri, joinedAt }}
715 roles={roles}
716 showRoleControls={showRoleControls}
717 />
718 );
719 }
720
721 let errorMsg: string;
722 if (appviewRes.status === 403) {
723 errorMsg = "Cannot assign a role with equal or higher authority than your own.";
724 } else if (appviewRes.status === 404) {
725 errorMsg = "Member or role not found.";
726 } else if (appviewRes.status === 401) {
727 errorMsg = "Your session has expired. Please log in again.";
728 } else {
729 logger.error("AppView returned error for role assignment", {
730 operation: "POST /admin/members/:did/role",
731 targetDid,
732 status: appviewRes.status,
733 });
734 errorMsg = "Something went wrong. Please try again.";
735 }
736
737 return c.html(
738 <MemberRow
739 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
740 roles={roles}
741 showRoleControls={showRoleControls}
742 errorMsg={errorMsg}
743 />
744 );
745 });
746
747 // ── GET /admin/structure ─────────────────────────────────────────────────
748
749 app.get("/admin/structure", async (c) => {
750 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
751 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
752
753 if (!auth.authenticated) {
754 return c.redirect("/login");
755 }
756
757 if (!canManageCategories(auth)) {
758 return c.html(
759 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
760 <PageHeader title="Forum Structure" />
761 <p>You don't have permission to manage forum structure.</p>
762 </BaseLayout>,
763 403
764 );
765 }
766
767 const cookie = c.req.header("cookie") ?? "";
768 const errorMsg = c.req.query("error") ?? null;
769
770 let categoriesRes: Response;
771 try {
772 categoriesRes = await fetch(`${appviewUrl}/api/categories`, {
773 headers: { Cookie: cookie },
774 });
775 } catch (error) {
776 if (isProgrammingError(error)) throw error;
777 logger.error("Network error fetching categories for structure page", {
778 operation: "GET /admin/structure",
779 error: error instanceof Error ? error.message : String(error),
780 });
781 return c.html(
782 <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
783 <PageHeader title="Forum Structure" />
784 <ErrorDisplay
785 message="Unable to load forum structure"
786 detail="The forum is temporarily unavailable. Please try again."
787 />
788 </BaseLayout>,
789 503
790 );
791 }
792
793 if (!categoriesRes.ok) {
794 if (categoriesRes.status === 401) {
795 return c.redirect("/login");
796 }
797 logger.error("AppView returned error for categories list", {
798 operation: "GET /admin/structure",
799 status: categoriesRes.status,
800 });
801 return c.html(
802 <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
803 <PageHeader title="Forum Structure" />
804 <ErrorDisplay
805 message="Something went wrong"
806 detail="Could not load forum structure. Please try again."
807 />
808 </BaseLayout>,
809 500
810 );
811 }
812
813 const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] };
814 const catList = categoriesData.categories;
815
816 // Fetch boards for each category in parallel (N+1 pattern — same as home.tsx)
817 let boardsPerCategory: BoardEntry[][];
818 try {
819 boardsPerCategory = await Promise.all(
820 catList.map((cat) =>
821 fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, {
822 headers: { Cookie: cookie },
823 })
824 .then((r) => r.json() as Promise<{ boards: BoardEntry[] }>)
825 .then((data) => data.boards)
826 .catch((error) => {
827 if (isProgrammingError(error)) throw error;
828 logger.error("Failed to fetch boards for category", {
829 operation: "GET /admin/structure",
830 categoryId: cat.id,
831 error: error instanceof Error ? error.message : String(error),
832 });
833 return [] as BoardEntry[];
834 })
835 )
836 );
837 } catch (error) {
838 if (isProgrammingError(error)) throw error;
839 logger.error("Failed to fetch boards for all categories", {
840 operation: "GET /admin/structure",
841 error: error instanceof Error ? error.message : String(error),
842 });
843 boardsPerCategory = catList.map(() => []);
844 }
845
846 const structure = catList.map((cat, i) => ({
847 category: cat,
848 boards: boardsPerCategory[i] ?? [],
849 }));
850
851 return c.html(
852 <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
853 <PageHeader title="Forum Structure" />
854 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
855 <div class="structure-page">
856 {structure.length === 0 ? (
857 <EmptyState message="No categories yet" />
858 ) : (
859 structure.map(({ category, boards }) => (
860 <StructureCategorySection category={category} boards={boards} />
861 ))
862 )}
863 <div class="structure-add-category card">
864 <h3>Add Category</h3>
865 <form method="post" action="/admin/structure/categories">
866 <div class="form-group">
867 <label for="new-cat-name">Name</label>
868 <input id="new-cat-name" type="text" name="name" required />
869 </div>
870 <div class="form-group">
871 <label for="new-cat-desc">Description</label>
872 <textarea id="new-cat-desc" name="description"></textarea>
873 </div>
874 <div class="form-group">
875 <label for="new-cat-sort">Sort Order</label>
876 <input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" />
877 </div>
878 <button type="submit" class="btn btn-primary">Add Category</button>
879 </form>
880 </div>
881 </div>
882 </BaseLayout>
883 );
884 });
885
886 // ── POST /admin/structure/categories ─────────────────────────────────────
887
888 app.post("/admin/structure/categories", async (c) => {
889 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
890 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
891 if (!auth.authenticated) return c.redirect("/login");
892 if (!canManageCategories(auth)) {
893 return c.html(
894 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
895 <PageHeader title="Forum Structure" />
896 <p>You don't have permission to manage forum structure.</p>
897 </BaseLayout>,
898 403
899 );
900 }
901
902 const cookie = c.req.header("cookie") ?? "";
903
904 let body: Record<string, string | File>;
905 try {
906 body = await c.req.parseBody();
907 } catch (error) {
908 if (isProgrammingError(error)) throw error;
909 return c.redirect(
910 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
911 302
912 );
913 }
914
915 const name = typeof body.name === "string" ? body.name.trim() : "";
916 if (!name) {
917 return c.redirect(
918 `/admin/structure?error=${encodeURIComponent("Category name is required.")}`,
919 302
920 );
921 }
922
923 const description = typeof body.description === "string" ? body.description.trim() || null : null;
924 const sortOrder = parseSortOrder(body.sortOrder);
925 if (sortOrder === null) {
926 return c.redirect(
927 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
928 302
929 );
930 }
931
932 let appviewRes: Response;
933 try {
934 appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, {
935 method: "POST",
936 headers: { "Content-Type": "application/json", Cookie: cookie },
937 body: JSON.stringify({ name, description, sortOrder }),
938 });
939 } catch (error) {
940 if (isProgrammingError(error)) throw error;
941 logger.error("Network error creating category", {
942 operation: "POST /admin/structure/categories",
943 error: error instanceof Error ? error.message : String(error),
944 });
945 return c.redirect(
946 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
947 302
948 );
949 }
950
951 if (!appviewRes.ok) {
952 const msg = await extractAppviewError(appviewRes, "Failed to create category. Please try again.");
953 logger.error("AppView error creating category", {
954 operation: "POST /admin/structure/categories",
955 status: appviewRes.status,
956 });
957 return c.redirect(
958 `/admin/structure?error=${encodeURIComponent(msg)}`,
959 302
960 );
961 }
962
963 return c.redirect("/admin/structure", 302);
964 });
965
966 // ── POST /admin/structure/categories/:id/edit ─────────────────────────────
967
968 app.post("/admin/structure/categories/:id/edit", async (c) => {
969 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
970 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
971 if (!auth.authenticated) return c.redirect("/login");
972 if (!canManageCategories(auth)) {
973 return c.html(
974 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
975 <PageHeader title="Forum Structure" />
976 <p>You don't have permission to manage forum structure.</p>
977 </BaseLayout>,
978 403
979 );
980 }
981
982 const categoryId = c.req.param("id");
983 const cookie = c.req.header("cookie") ?? "";
984
985 let body: Record<string, string | File>;
986 try {
987 body = await c.req.parseBody();
988 } catch (error) {
989 if (isProgrammingError(error)) throw error;
990 return c.redirect(
991 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
992 302
993 );
994 }
995
996 const name = typeof body.name === "string" ? body.name.trim() : "";
997 if (!name) {
998 return c.redirect(
999 `/admin/structure?error=${encodeURIComponent("Category name is required.")}`,
1000 302
1001 );
1002 }
1003
1004 const description = typeof body.description === "string" ? body.description.trim() || null : null;
1005 const sortOrder = parseSortOrder(body.sortOrder);
1006 if (sortOrder === null) {
1007 return c.redirect(
1008 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
1009 302
1010 );
1011 }
1012
1013 let appviewRes: Response;
1014 try {
1015 appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, {
1016 method: "PUT",
1017 headers: { "Content-Type": "application/json", Cookie: cookie },
1018 body: JSON.stringify({ name, description, sortOrder }),
1019 });
1020 } catch (error) {
1021 if (isProgrammingError(error)) throw error;
1022 logger.error("Network error editing category", {
1023 operation: "POST /admin/structure/categories/:id/edit",
1024 categoryId,
1025 error: error instanceof Error ? error.message : String(error),
1026 });
1027 return c.redirect(
1028 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1029 302
1030 );
1031 }
1032
1033 if (!appviewRes.ok) {
1034 const msg = await extractAppviewError(appviewRes, "Failed to update category. Please try again.");
1035 logger.error("AppView error editing category", {
1036 operation: "POST /admin/structure/categories/:id/edit",
1037 categoryId,
1038 status: appviewRes.status,
1039 });
1040 return c.redirect(
1041 `/admin/structure?error=${encodeURIComponent(msg)}`,
1042 302
1043 );
1044 }
1045
1046 return c.redirect("/admin/structure", 302);
1047 });
1048
1049 // ── POST /admin/structure/categories/:id/delete ───────────────────────────
1050
1051 app.post("/admin/structure/categories/:id/delete", async (c) => {
1052 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1053 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1054 if (!auth.authenticated) return c.redirect("/login");
1055 if (!canManageCategories(auth)) {
1056 return c.html(
1057 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1058 <PageHeader title="Forum Structure" />
1059 <p>You don't have permission to manage forum structure.</p>
1060 </BaseLayout>,
1061 403
1062 );
1063 }
1064
1065 const categoryId = c.req.param("id");
1066 const cookie = c.req.header("cookie") ?? "";
1067
1068 let appviewRes: Response;
1069 try {
1070 appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, {
1071 method: "DELETE",
1072 headers: { Cookie: cookie },
1073 });
1074 } catch (error) {
1075 if (isProgrammingError(error)) throw error;
1076 logger.error("Network error deleting category", {
1077 operation: "POST /admin/structure/categories/:id/delete",
1078 categoryId,
1079 error: error instanceof Error ? error.message : String(error),
1080 });
1081 return c.redirect(
1082 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1083 302
1084 );
1085 }
1086
1087 if (!appviewRes.ok) {
1088 const msg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again.");
1089 logger.error("AppView error deleting category", {
1090 operation: "POST /admin/structure/categories/:id/delete",
1091 categoryId,
1092 status: appviewRes.status,
1093 });
1094 return c.redirect(
1095 `/admin/structure?error=${encodeURIComponent(msg)}`,
1096 302
1097 );
1098 }
1099
1100 return c.redirect("/admin/structure", 302);
1101 });
1102
1103 // ── POST /admin/structure/boards ──────────────────────────────────────────
1104
1105 app.post("/admin/structure/boards", async (c) => {
1106 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1107 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1108 if (!auth.authenticated) return c.redirect("/login");
1109 if (!canManageCategories(auth)) {
1110 return c.html(
1111 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1112 <PageHeader title="Forum Structure" />
1113 <p>You don't have permission to manage forum structure.</p>
1114 </BaseLayout>,
1115 403
1116 );
1117 }
1118
1119 const cookie = c.req.header("cookie") ?? "";
1120
1121 let body: Record<string, string | File>;
1122 try {
1123 body = await c.req.parseBody();
1124 } catch (error) {
1125 if (isProgrammingError(error)) throw error;
1126 return c.redirect(
1127 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
1128 302
1129 );
1130 }
1131
1132 const name = typeof body.name === "string" ? body.name.trim() : "";
1133 if (!name) {
1134 return c.redirect(
1135 `/admin/structure?error=${encodeURIComponent("Board name is required.")}`,
1136 302
1137 );
1138 }
1139
1140 const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : "";
1141 if (!categoryUri) {
1142 return c.redirect(
1143 `/admin/structure?error=${encodeURIComponent("Category is required to create a board.")}`,
1144 302
1145 );
1146 }
1147
1148 const description = typeof body.description === "string" ? body.description.trim() || null : null;
1149 const sortOrder = parseSortOrder(body.sortOrder);
1150 if (sortOrder === null) {
1151 return c.redirect(
1152 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
1153 302
1154 );
1155 }
1156
1157 let appviewRes: Response;
1158 try {
1159 appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, {
1160 method: "POST",
1161 headers: { "Content-Type": "application/json", Cookie: cookie },
1162 body: JSON.stringify({ name, description, sortOrder, categoryUri }),
1163 });
1164 } catch (error) {
1165 if (isProgrammingError(error)) throw error;
1166 logger.error("Network error creating board", {
1167 operation: "POST /admin/structure/boards",
1168 error: error instanceof Error ? error.message : String(error),
1169 });
1170 return c.redirect(
1171 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1172 302
1173 );
1174 }
1175
1176 if (!appviewRes.ok) {
1177 const msg = await extractAppviewError(appviewRes, "Failed to create board. Please try again.");
1178 logger.error("AppView error creating board", {
1179 operation: "POST /admin/structure/boards",
1180 status: appviewRes.status,
1181 });
1182 return c.redirect(
1183 `/admin/structure?error=${encodeURIComponent(msg)}`,
1184 302
1185 );
1186 }
1187
1188 return c.redirect("/admin/structure", 302);
1189 });
1190
1191 // ── POST /admin/structure/boards/:id/edit ─────────────────────────────────
1192
1193 app.post("/admin/structure/boards/:id/edit", async (c) => {
1194 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1195 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1196 if (!auth.authenticated) return c.redirect("/login");
1197 if (!canManageCategories(auth)) {
1198 return c.html(
1199 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1200 <PageHeader title="Forum Structure" />
1201 <p>You don't have permission to manage forum structure.</p>
1202 </BaseLayout>,
1203 403
1204 );
1205 }
1206
1207 const boardId = c.req.param("id");
1208 const cookie = c.req.header("cookie") ?? "";
1209
1210 let body: Record<string, string | File>;
1211 try {
1212 body = await c.req.parseBody();
1213 } catch (error) {
1214 if (isProgrammingError(error)) throw error;
1215 return c.redirect(
1216 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
1217 302
1218 );
1219 }
1220
1221 const name = typeof body.name === "string" ? body.name.trim() : "";
1222 if (!name) {
1223 return c.redirect(
1224 `/admin/structure?error=${encodeURIComponent("Board name is required.")}`,
1225 302
1226 );
1227 }
1228
1229 const description = typeof body.description === "string" ? body.description.trim() || null : null;
1230 const sortOrder = parseSortOrder(body.sortOrder);
1231 if (sortOrder === null) {
1232 return c.redirect(
1233 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
1234 302
1235 );
1236 }
1237
1238 let appviewRes: Response;
1239 try {
1240 appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, {
1241 method: "PUT",
1242 headers: { "Content-Type": "application/json", Cookie: cookie },
1243 body: JSON.stringify({ name, description, sortOrder }),
1244 });
1245 } catch (error) {
1246 if (isProgrammingError(error)) throw error;
1247 logger.error("Network error editing board", {
1248 operation: "POST /admin/structure/boards/:id/edit",
1249 boardId,
1250 error: error instanceof Error ? error.message : String(error),
1251 });
1252 return c.redirect(
1253 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1254 302
1255 );
1256 }
1257
1258 if (!appviewRes.ok) {
1259 const msg = await extractAppviewError(appviewRes, "Failed to update board. Please try again.");
1260 logger.error("AppView error editing board", {
1261 operation: "POST /admin/structure/boards/:id/edit",
1262 boardId,
1263 status: appviewRes.status,
1264 });
1265 return c.redirect(
1266 `/admin/structure?error=${encodeURIComponent(msg)}`,
1267 302
1268 );
1269 }
1270
1271 return c.redirect("/admin/structure", 302);
1272 });
1273
1274 // ── POST /admin/structure/boards/:id/delete ───────────────────────────────
1275
1276 app.post("/admin/structure/boards/:id/delete", async (c) => {
1277 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1278 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1279 if (!auth.authenticated) return c.redirect("/login");
1280 if (!canManageCategories(auth)) {
1281 return c.html(
1282 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1283 <PageHeader title="Forum Structure" />
1284 <p>You don't have permission to manage forum structure.</p>
1285 </BaseLayout>,
1286 403
1287 );
1288 }
1289
1290 const boardId = c.req.param("id");
1291 const cookie = c.req.header("cookie") ?? "";
1292
1293 let appviewRes: Response;
1294 try {
1295 appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, {
1296 method: "DELETE",
1297 headers: { Cookie: cookie },
1298 });
1299 } catch (error) {
1300 if (isProgrammingError(error)) throw error;
1301 logger.error("Network error deleting board", {
1302 operation: "POST /admin/structure/boards/:id/delete",
1303 boardId,
1304 error: error instanceof Error ? error.message : String(error),
1305 });
1306 return c.redirect(
1307 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1308 302
1309 );
1310 }
1311
1312 if (!appviewRes.ok) {
1313 const msg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again.");
1314 logger.error("AppView error deleting board", {
1315 operation: "POST /admin/structure/boards/:id/delete",
1316 boardId,
1317 status: appviewRes.status,
1318 });
1319 return c.redirect(
1320 `/admin/structure?error=${encodeURIComponent(msg)}`,
1321 302
1322 );
1323 }
1324
1325 return c.redirect("/admin/structure", 302);
1326 });
1327
1328 // ── GET /admin/modlog ─────────────────────────────────────────────────────
1329
1330 app.get("/admin/modlog", async (c) => {
1331 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1332 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1333
1334 if (!auth.authenticated) {
1335 return c.redirect("/login");
1336 }
1337
1338 if (!canViewModLog(auth)) {
1339 return c.html(
1340 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1341 <PageHeader title="Mod Action Log" />
1342 <p>You don't have permission to view the mod action log.</p>
1343 </BaseLayout>,
1344 403
1345 );
1346 }
1347
1348 const rawOffset = c.req.query("offset");
1349 const offset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
1350 ? parseInt(rawOffset, 10)
1351 : 0;
1352 const limit = 50;
1353
1354 const cookie = c.req.header("cookie") ?? "";
1355
1356 let modlogRes: Response;
1357 try {
1358 modlogRes = await fetch(
1359 `${appviewUrl}/api/admin/modlog?limit=${limit}&offset=${offset}`,
1360 { headers: { Cookie: cookie } }
1361 );
1362 } catch (error) {
1363 if (isProgrammingError(error)) throw error;
1364 logger.error("Network error fetching mod action log", {
1365 operation: "GET /admin/modlog",
1366 error: error instanceof Error ? error.message : String(error),
1367 });
1368 return c.html(
1369 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1370 <PageHeader title="Mod Action Log" />
1371 <ErrorDisplay
1372 message="Unable to load mod action log"
1373 detail="The forum is temporarily unavailable. Please try again."
1374 />
1375 </BaseLayout>,
1376 503
1377 );
1378 }
1379
1380 if (!modlogRes.ok) {
1381 if (modlogRes.status === 401) {
1382 return c.redirect("/login");
1383 }
1384 logger.error("AppView returned error for mod action log", {
1385 operation: "GET /admin/modlog",
1386 status: modlogRes.status,
1387 });
1388 return c.html(
1389 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1390 <PageHeader title="Mod Action Log" />
1391 <ErrorDisplay
1392 message="Something went wrong"
1393 detail="Could not load mod action log. Please try again."
1394 />
1395 </BaseLayout>,
1396 500
1397 );
1398 }
1399
1400 let data: { actions: ModLogEntry[]; total: number; offset: number; limit: number };
1401 try {
1402 data = (await modlogRes.json()) as {
1403 actions: ModLogEntry[];
1404 total: number;
1405 offset: number;
1406 limit: number;
1407 };
1408 } catch (error) {
1409 if (!(error instanceof SyntaxError)) throw error;
1410 logger.error("Malformed JSON from AppView mod action log response", {
1411 operation: "GET /admin/modlog",
1412 });
1413 return c.html(
1414 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1415 <PageHeader title="Mod Action Log" />
1416 <ErrorDisplay
1417 message="Something went wrong"
1418 detail="Could not load mod action log. Please try again."
1419 />
1420 </BaseLayout>,
1421 500
1422 );
1423 }
1424
1425 const { actions, total } = data;
1426 const totalPages = total === 0 ? 1 : Math.ceil(total / limit);
1427 const currentPage = Math.floor(offset / limit) + 1;
1428 const hasPrev = offset > 0;
1429 const hasNext = offset + limit < total;
1430
1431 return c.html(
1432 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
1433 <PageHeader title="Mod Action Log" />
1434 {actions.length === 0 ? (
1435 <EmptyState message="No moderation actions yet" />
1436 ) : (
1437 <div class="card">
1438 <table class="modlog-table">
1439 <thead>
1440 <tr>
1441 <th scope="col">Time</th>
1442 <th scope="col">Moderator</th>
1443 <th scope="col">Action</th>
1444 <th scope="col">Subject</th>
1445 <th scope="col">Reason</th>
1446 </tr>
1447 </thead>
1448 <tbody>
1449 {actions.map((entry) => (
1450 <ModLogRow entry={entry} />
1451 ))}
1452 </tbody>
1453 </table>
1454 </div>
1455 )}
1456 <div class="modlog-pagination">
1457 {hasPrev && (
1458 <a href={`/admin/modlog?offset=${offset - limit}`} class="btn btn-secondary">
1459 ← Previous
1460 </a>
1461 )}
1462 <span class="modlog-pagination__indicator">
1463 Page {currentPage} of {totalPages}
1464 </span>
1465 {hasNext && (
1466 <a href={`/admin/modlog?offset=${offset + limit}`} class="btn btn-secondary">
1467 Next →
1468 </a>
1469 )}
1470 </div>
1471 </BaseLayout>
1472 );
1473 });
1474
1475 // ─── Themes ────────────────────────────────────────────────────────────────
1476
1477 app.route("/", createAdminThemeRoutes(appviewUrl));
1478
1479 return app;
1480}