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 {
4 PageHeader,
5 EmptyState,
6 ErrorDisplay,
7 Card,
8} from "../components/index.js";
9import { fetchApi } from "../lib/api.js";
10import { getSession } from "../lib/session.js";
11import { isProgrammingError, isNetworkError } from "../lib/errors.js";
12import { logger } from "../lib/logger.js";
13import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js";
14
15// API response type shapes
16interface ForumResponse {
17 id: string;
18 did: string;
19 name: string;
20 description: string | null;
21 indexedAt: string;
22}
23
24interface CategoryResponse {
25 id: string;
26 did: string;
27 name: string;
28 description: string | null;
29 slug: string | null;
30 sortOrder: number | null;
31}
32
33interface BoardResponse {
34 id: string;
35 did: string;
36 name: string;
37 description: string | null;
38 slug: string | null;
39 sortOrder: number | null;
40}
41
42interface CategoriesListResponse {
43 categories: CategoryResponse[];
44}
45
46interface BoardsListResponse {
47 boards: BoardResponse[];
48}
49
50
51export function createHomeRoutes(appviewUrl: string) {
52 return new Hono<WebAppEnv>().get("/", async (c) => {
53 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
54 const auth = await getSession(appviewUrl, c.req.header("cookie"));
55
56 // Stage 1: fetch forum metadata and category list in parallel
57 let forum: ForumResponse;
58 let categories: CategoryResponse[];
59 try {
60 const [forumData, categoriesData] = await Promise.all([
61 fetchApi<ForumResponse>("/forum"),
62 fetchApi<CategoriesListResponse>("/categories"),
63 ]);
64 forum = forumData;
65 categories = categoriesData.categories;
66 } catch (error) {
67 if (isProgrammingError(error)) throw error;
68 logger.error("Failed to load forum homepage data (stage 1: forum + categories)", {
69 operation: "GET /",
70 error: error instanceof Error ? error.message : String(error),
71 });
72 const status = isNetworkError(error) ? 503 : 500;
73 const message =
74 status === 503
75 ? "The forum is temporarily unavailable. Please try again later."
76 : "Something went wrong loading the forum. Please try again later.";
77 return c.html(
78 <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
79 <ErrorDisplay message={message} />
80 </BaseLayout>,
81 status
82 );
83 }
84
85 // Stage 2: fetch boards for each category in parallel
86 let boardsByCategory: BoardResponse[][];
87 try {
88 boardsByCategory = await Promise.all(
89 categories.map((cat) =>
90 fetchApi<BoardsListResponse>(`/categories/${cat.id}/boards`).then(
91 (data) => data.boards
92 )
93 )
94 );
95 } catch (error) {
96 if (isProgrammingError(error)) throw error;
97 logger.error("Failed to load forum homepage data (stage 2: boards)", {
98 operation: "GET /",
99 error: error instanceof Error ? error.message : String(error),
100 });
101 const status = isNetworkError(error) ? 503 : 500;
102 const message =
103 status === 503
104 ? "The forum is temporarily unavailable. Please try again later."
105 : "Something went wrong loading the forum. Please try again later.";
106 return c.html(
107 <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
108 <ErrorDisplay message={message} />
109 </BaseLayout>,
110 status
111 );
112 }
113
114 // Build category+boards pairs for rendering
115 const categorySections = categories.map((cat, i) => ({
116 category: cat,
117 boards: boardsByCategory[i] ?? [],
118 }));
119
120 return c.html(
121 <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}>
122 <PageHeader title={forum.name} description={forum.description ?? undefined} />
123 {categorySections.length === 0 ? (
124 <EmptyState message="No categories yet." />
125 ) : (
126 categorySections.map(({ category, boards }) => (
127 <section class="category-section" key={category.id}>
128 <h2 class="category-header">{category.name}</h2>
129 <div class="board-grid">
130 {boards.length === 0 ? (
131 <EmptyState message="No boards in this category yet." />
132 ) : (
133 boards.map((board) => (
134 <a href={`/boards/${board.id}`} class="board-card" key={board.id}>
135 <Card>
136 <p class="board-card__name">{board.name}</p>
137 {board.description && (
138 <p class="board-card__description">{board.description}</p>
139 )}
140 </Card>
141 </a>
142 ))
143 )}
144 </div>
145 </section>
146 ))
147 )}
148 </BaseLayout>
149 );
150 });
151}