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, EmptyState, ErrorDisplay } from "../components/index.js";
4import { fetchApi } from "../lib/api.js";
5import { getSession } from "../lib/session.js";
6import { isProgrammingError, isNetworkError, isNotFoundError } from "../lib/errors.js";
7import { timeAgo } from "../lib/time.js";
8import { logger } from "../lib/logger.js";
9import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js";
10
11// API response type shapes
12
13interface BoardResponse {
14 id: string;
15 did: string;
16 uri: string;
17 name: string;
18 description: string | null;
19 slug: string | null;
20 sortOrder: number | null;
21 categoryId: string;
22 categoryUri: string | null;
23 createdAt: string | null;
24 indexedAt: string | null;
25}
26
27interface CategoryResponse {
28 id: string;
29 did: string;
30 name: string;
31 description: string | null;
32 slug: string | null;
33 sortOrder: number | null;
34 forumId: string | null;
35 createdAt: string | null;
36 indexedAt: string | null;
37}
38
39interface AuthorResponse {
40 did: string;
41 handle: string | null;
42}
43
44interface TopicResponse {
45 id: string;
46 did: string;
47 rkey: string;
48 title: string | null;
49 text: string;
50 forumUri: string | null;
51 boardUri: string | null;
52 boardId: string | null;
53 parentPostId: string | null;
54 createdAt: string | null;
55 author: AuthorResponse | null;
56 replyCount: number;
57 lastReplyAt: string | null;
58}
59
60interface TopicsListResponse {
61 topics: TopicResponse[];
62 total: number;
63 offset: number;
64 limit: number;
65}
66
67// ─── Inline components ──────────────────────────────────────────────────────
68
69function TopicRow({ topic }: { topic: TopicResponse }) {
70 const title = topic.title ?? topic.text.slice(0, 80);
71 const handle = topic.author?.handle ?? topic.author?.did ?? topic.did;
72 const replyLabel = topic.replyCount === 1 ? "1 reply" : `${topic.replyCount} replies`;
73 const dateLabel = topic.lastReplyAt
74 ? `last reply ${timeAgo(new Date(topic.lastReplyAt))}`
75 : topic.createdAt
76 ? timeAgo(new Date(topic.createdAt))
77 : "unknown";
78 return (
79 <div class="topic-row">
80 <a href={`/topics/${topic.id}`} class="topic-row__title">
81 {title}
82 </a>
83 <div class="topic-row__meta">
84 <span>by {handle}</span>
85 <span>{dateLabel}</span>
86 <span>{replyLabel}</span>
87 </div>
88 </div>
89 );
90}
91
92function LoadMoreButton({
93 boardId,
94 nextOffset,
95}: {
96 boardId: string;
97 nextOffset: number;
98}) {
99 return (
100 <button
101 hx-get={`/boards/${boardId}?offset=${nextOffset}`}
102 hx-swap="outerHTML"
103 hx-target="this"
104 hx-indicator="#loading-spinner"
105 >
106 Load More
107 </button>
108 );
109}
110
111function TopicFragment({
112 boardId,
113 topics,
114 total,
115 offset,
116}: {
117 boardId: string;
118 topics: TopicResponse[];
119 total: number;
120 offset: number;
121}) {
122 const nextOffset = offset + topics.length;
123 const hasMore = nextOffset < total;
124 return (
125 <>
126 {topics.map((topic) => (
127 <TopicRow key={topic.id} topic={topic} />
128 ))}
129 {hasMore && (
130 <LoadMoreButton boardId={boardId} nextOffset={nextOffset} />
131 )}
132 </>
133 );
134}
135
136// ─── Route factory ───────────────────────────────────────────────────────────
137
138export function createBoardsRoutes(appviewUrl: string) {
139 return new Hono<WebAppEnv>().get("/boards/:id", async (c) => {
140 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
141 const idParam = c.req.param("id");
142
143 // Validate that the ID is an integer (parseable as BigInt)
144 if (!/^\d+$/.test(idParam)) {
145 // HTMX partial mode: return empty fragment silently
146 if (c.req.header("HX-Request")) {
147 return c.html("", 200);
148 }
149 return c.html(
150 <BaseLayout title="Bad Request — atBB Forum" resolvedTheme={resolvedTheme}>
151 <ErrorDisplay message="Invalid board ID." />
152 </BaseLayout>,
153 400
154 );
155 }
156
157 const boardId = idParam;
158
159 // ── HTMX partial mode ──────────────────────────────────────────────────
160 if (c.req.header("HX-Request")) {
161 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
162 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
163
164 try {
165 const topicsData = await fetchApi<TopicsListResponse>(
166 `/boards/${boardId}/topics?offset=${offset}&limit=25`
167 );
168 const { topics, total } = topicsData;
169
170 return c.html(
171 <TopicFragment
172 boardId={boardId}
173 topics={topics}
174 total={total}
175 offset={offset}
176 />,
177 200
178 );
179 } catch (error) {
180 if (isProgrammingError(error)) throw error;
181 logger.error("Failed to load topics for HTMX partial request", {
182 operation: "GET /boards/:id (HTMX partial)",
183 boardId,
184 offset,
185 error: error instanceof Error ? error.message : String(error),
186 });
187 // On any error in HTMX partial mode, return empty fragment to avoid breaking the page
188 return c.html("", 200);
189 }
190 }
191
192 // ── Full page mode ────────────────────────────────────────────────────
193 const postedSuccess = c.req.query("posted") === "1";
194 const auth = await getSession(appviewUrl, c.req.header("cookie"));
195
196 // Stage 1: fetch board metadata and topics in parallel
197 let board: BoardResponse;
198 let topicsData: TopicsListResponse;
199 try {
200 const [boardResult, topicsResult] = await Promise.all([
201 fetchApi<BoardResponse>(`/boards/${boardId}`),
202 fetchApi<TopicsListResponse>(`/boards/${boardId}/topics?offset=0&limit=25`),
203 ]);
204 board = boardResult;
205 topicsData = topicsResult;
206 } catch (error) {
207 if (isProgrammingError(error)) throw error;
208
209 if (isNotFoundError(error)) {
210 return c.html(
211 <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
212 <ErrorDisplay message="This board doesn't exist." />
213 </BaseLayout>,
214 404
215 );
216 }
217
218 logger.error("Failed to load board page data (stage 1: board + topics)", {
219 operation: "GET /boards/:id",
220 boardId,
221 error: error instanceof Error ? error.message : String(error),
222 });
223 const status = isNetworkError(error) ? 503 : 500;
224 const message =
225 status === 503
226 ? "The forum is temporarily unavailable. Please try again later."
227 : "Something went wrong loading this board. Please try again later.";
228 return c.html(
229 <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
230 <ErrorDisplay message={message} />
231 </BaseLayout>,
232 status
233 );
234 }
235
236 // Stage 2: fetch category for breadcrumb (non-fatal — page renders without category name)
237 let categoryName: string | null = null;
238 try {
239 const category = await fetchApi<CategoryResponse>(`/categories/${board.categoryId}`);
240 categoryName = category.name;
241 } catch (error) {
242 if (isProgrammingError(error)) throw error;
243 logger.error("Failed to load board page data (stage 2: category)", {
244 operation: "GET /boards/:id",
245 boardId: board.id,
246 categoryId: board.categoryId,
247 error: error instanceof Error ? error.message : String(error),
248 });
249 // categoryName remains null — page renders without category in breadcrumb
250 }
251
252 const { topics, total, offset } = topicsData;
253
254 return c.html(
255 <BaseLayout title={`${board.name} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}>
256 <nav class="breadcrumb" aria-label="Breadcrumb">
257 <ol>
258 <li><a href="/">Home</a></li>
259 {categoryName && <li><a href="/">{categoryName}</a></li>}
260 <li><span>{board.name}</span></li>
261 </ol>
262 </nav>
263
264 <PageHeader
265 title={board.name}
266 description={board.description ?? undefined}
267 />
268
269 {postedSuccess && auth?.authenticated && (
270 <div class="success-banner">
271 Your topic has been posted. It will appear shortly.
272 </div>
273 )}
274
275 {auth?.authenticated ? (
276 <a href={`/new-topic?boardId=${boardId}`} class="btn">
277 Start a new topic
278 </a>
279 ) : (
280 <p>
281 <a href="/login">Log in</a> to start a topic
282 </p>
283 )}
284
285 <div id="topic-list">
286 {topics.length === 0 ? (
287 <EmptyState message="No topics yet." />
288 ) : (
289 <TopicFragment
290 boardId={boardId}
291 topics={topics}
292 total={total}
293 offset={offset}
294 />
295 )}
296 </div>
297 </BaseLayout>
298 );
299 });
300}