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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1480 lines 53 kB view raw
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 &quot;{board.name}&quot;? 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 &quot;{category.name}&quot;? 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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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}