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