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