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

feat(web): add GET /admin/structure page with category/board listing (ATB-47)

+304
+304
apps/web/src/routes/admin.tsx
··· 121 121 ); 122 122 } 123 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 + */ 130 + async 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 invalid or missing values. 142 + */ 143 + function parseSortOrder(value: unknown): number { 144 + if (typeof value !== "string") return 0; 145 + const n = parseInt(value, 10); 146 + return Number.isFinite(n) && n >= 0 ? n : 0; 147 + } 148 + 124 149 // ─── Routes ──────────────────────────────────────────────────────────────── 125 150 126 151 export function createAdminRoutes(appviewUrl: string) { 127 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 &quot;{board.name}&quot;? 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 &quot;{category.name}&quot;? 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 + } 128 306 129 307 // ── GET /admin ──────────────────────────────────────────────────────────── 130 308 ··· 491 669 showRoleControls={showRoleControls} 492 670 errorMsg={errorMsg} 493 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&apos;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(() => [] as BoardEntry[]) 754 + ) 755 + ); 756 + } catch (error) { 757 + if (isProgrammingError(error)) throw error; 758 + boardsPerCategory = catList.map(() => []); 759 + } 760 + 761 + const structure = catList.map((cat, i) => ({ 762 + category: cat, 763 + boards: boardsPerCategory[i] ?? [], 764 + })); 765 + 766 + return c.html( 767 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 768 + <PageHeader title="Forum Structure" /> 769 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 770 + <div class="structure-page"> 771 + {structure.length === 0 ? ( 772 + <EmptyState message="No categories yet" /> 773 + ) : ( 774 + structure.map(({ category, boards }) => ( 775 + <StructureCategorySection category={category} boards={boards} /> 776 + )) 777 + )} 778 + <div class="structure-add-category card"> 779 + <h3>Add Category</h3> 780 + <form method="POST" action="/admin/structure/categories"> 781 + <div class="form-group"> 782 + <label for="new-cat-name">Name</label> 783 + <input id="new-cat-name" type="text" name="name" required /> 784 + </div> 785 + <div class="form-group"> 786 + <label for="new-cat-desc">Description</label> 787 + <textarea id="new-cat-desc" name="description"></textarea> 788 + </div> 789 + <div class="form-group"> 790 + <label for="new-cat-sort">Sort Order</label> 791 + <input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" /> 792 + </div> 793 + <button type="submit" class="btn btn-primary">Add Category</button> 794 + </form> 795 + </div> 796 + </div> 797 + </BaseLayout> 494 798 ); 495 799 }); 496 800