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, ErrorDisplay } from "../components/index.js";
4import { getSession } from "../lib/session.js";
5import { fetchApi } from "../lib/api.js";
6import {
7 isProgrammingError,
8 isNotFoundError,
9} from "../lib/errors.js";
10import { logger } from "../lib/logger.js";
11import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js";
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
27const CHAR_COUNTER_SCRIPT = `
28 function updateCharCount(el) {
29 var seg = new Intl.Segmenter();
30 var n = Array.from(seg.segment(el.value)).length;
31 var counter = document.getElementById("char-count");
32 counter.textContent = (300 - n) + " left";
33 counter.dataset.over = n > 300 ? "true" : "false";
34 }
35 function updateTitleCharCount(el) {
36 var seg = new Intl.Segmenter();
37 var n = Array.from(seg.segment(el.value)).length;
38 var counter = document.getElementById("title-char-count");
39 counter.textContent = (120 - n) + " left";
40 counter.dataset.over = n > 120 ? "true" : "false";
41 }
42`;
43
44export function createNewTopicRoutes(appviewUrl: string) {
45 return new Hono<WebAppEnv>()
46 .get("/new-topic", async (c) => {
47 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
48 const boardIdParam = c.req.query("boardId");
49
50 // boardId required and must be numeric
51 if (!boardIdParam || !/^\d+$/.test(boardIdParam)) {
52 return c.redirect("/");
53 }
54
55 const auth = await getSession(appviewUrl, c.req.header("cookie"));
56
57 if (!auth.authenticated) {
58 return c.html(
59 <BaseLayout title="New Topic — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
60 <PageHeader title="New Topic" />
61 <p>
62 <a href="/login">Log in</a> to create a topic.
63 </p>
64 </BaseLayout>
65 );
66 }
67
68 // Fetch board data — need the AT URI for the hidden form field
69 let board: BoardResponse;
70 try {
71 board = await fetchApi<BoardResponse>(`/boards/${boardIdParam}`);
72 } catch (error) {
73 if (isProgrammingError(error)) throw error;
74
75 if (isNotFoundError(error)) {
76 return c.html(
77 <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
78 <ErrorDisplay message="This board doesn't exist." />
79 </BaseLayout>,
80 404
81 );
82 }
83
84 logger.error("Failed to load board for new topic form", {
85 operation: "GET /new-topic",
86 boardId: boardIdParam,
87 error: error instanceof Error ? error.message : String(error),
88 });
89 return c.redirect("/");
90 }
91
92 return c.html(
93 <BaseLayout title="New Topic — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
94 <nav class="breadcrumb" aria-label="Breadcrumb">
95 <ol>
96 <li><a href="/">Home</a></li>
97 <li><a href={`/boards/${board.id}`}>{board.name}</a></li>
98 <li><span>New Topic</span></li>
99 </ol>
100 </nav>
101
102 <PageHeader title="New Topic" description={`Posting to ${board.name}`} />
103
104 <form
105 hx-post="/new-topic"
106 hx-target="#form-error"
107 hx-swap="innerHTML"
108 hx-indicator="#spinner"
109 hx-disabled-elt="[type=submit]"
110 >
111 <input type="hidden" name="boardUri" value={board.uri} />
112 <input type="hidden" name="boardId" value={board.id} />
113
114 <div class="form-group">
115 <label for="compose-title">Topic title</label>
116 <input
117 id="compose-title"
118 name="title"
119 type="text"
120 placeholder="Give your topic a title"
121 oninput="updateTitleCharCount(this)"
122 aria-required="true"
123 aria-describedby="title-char-count"
124 />
125 <div id="title-char-count" class="char-count" aria-live="polite">120 left</div>
126 </div>
127
128 <div class="form-group">
129 <label for="compose-text">Your message</label>
130 <textarea
131 id="compose-text"
132 name="text"
133 rows={8}
134 placeholder="What's on your mind?"
135 oninput="updateCharCount(this)"
136 aria-required="true"
137 aria-describedby="char-count"
138 />
139 <div id="char-count" class="char-count" aria-live="polite">300 left</div>
140 </div>
141
142 <div id="form-error" role="alert" />
143
144 <div class="form-actions">
145 <button type="submit" class="btn btn-primary">
146 Post Topic
147 <span id="spinner" class="htmx-indicator">Posting…</span>
148 </button>
149 </div>
150 </form>
151
152 <script dangerouslySetInnerHTML={{ __html: CHAR_COUNTER_SCRIPT }} />
153 </BaseLayout>
154 );
155 })
156 .post("/new-topic", async (c) => {
157 const cookieHeader = c.req.header("cookie") ?? "";
158
159 // Parse URL-encoded form body (HTMX default encoding)
160 let body: Record<string, string | File>;
161 try {
162 body = await c.req.parseBody();
163 } catch (error) {
164 logger.error("Failed to parse request body for POST /new-topic", {
165 operation: "POST /new-topic",
166 error: error instanceof Error ? error.message : String(error),
167 });
168 return c.html(<p class="form-error">Invalid form submission.</p>);
169 }
170
171 const { title, text, boardUri, boardId } = body;
172
173 // Validate required fields before proxying
174 if (typeof title !== "string" || !title.trim()) {
175 return c.html(<p class="form-error">Topic title is required.</p>);
176 }
177 if (typeof text !== "string" || !text.trim()) {
178 return c.html(<p class="form-error">Message text is required.</p>);
179 }
180 if (typeof boardUri !== "string" || !boardUri.trim()) {
181 return c.html(
182 <p class="form-error">Board information is missing. Please try again.</p>
183 );
184 }
185 if (typeof boardId !== "string" || !/^\d+$/.test(boardId)) {
186 return c.html(
187 <p class="form-error">Board information is missing. Please try again.</p>
188 );
189 }
190
191 // Proxy to AppView
192 let appviewRes: Response;
193 try {
194 appviewRes = await fetch(`${appviewUrl}/api/topics`, {
195 method: "POST",
196 headers: {
197 "Content-Type": "application/json",
198 "Cookie": cookieHeader,
199 },
200 body: JSON.stringify({ title, text, boardUri }),
201 });
202 } catch (error) {
203 if (isProgrammingError(error)) throw error;
204 logger.error("Failed to proxy new topic to AppView", {
205 operation: "POST /new-topic",
206 error: error instanceof Error ? error.message : String(error),
207 });
208 return c.html(
209 <p class="form-error">Forum temporarily unavailable. Please try again.</p>
210 );
211 }
212
213 // Success: instruct HTMX to navigate to the board with a flash message
214 if (appviewRes.status === 201) {
215 const headers = new Headers();
216 headers.set("HX-Redirect", `/boards/${boardId}?posted=1`);
217 return new Response(null, { status: 200, headers });
218 }
219
220 // Map AppView error to user-friendly message
221 let errorMessage = "Something went wrong. Please try again.";
222
223 if (appviewRes.status === 401) {
224 errorMessage = "You must be logged in to post.";
225 } else if (appviewRes.status === 403) {
226 try {
227 const errBody = (await appviewRes.json()) as { error?: string };
228 const msg = errBody.error ?? "";
229 if (msg.toLowerCase().includes("banned")) {
230 errorMessage = "You are banned from this forum.";
231 } else {
232 errorMessage = msg || "You are not allowed to create topics.";
233 }
234 } catch {
235 logger.error("Failed to parse AppView 403 response body for new topic", {
236 operation: "POST /new-topic",
237 });
238 errorMessage = "You are not allowed to create topics.";
239 }
240 } else if (appviewRes.status === 400) {
241 try {
242 const errBody = (await appviewRes.json()) as { error?: string };
243 errorMessage = errBody.error ?? errorMessage;
244 } catch {
245 logger.error("Failed to parse AppView 400 response body for new topic", {
246 operation: "POST /new-topic",
247 });
248 }
249 } else if (appviewRes.status >= 400 && appviewRes.status < 500) {
250 logger.error("AppView returned unexpected client error for new topic", {
251 operation: "POST /new-topic",
252 status: appviewRes.status,
253 });
254 } else if (appviewRes.status >= 500) {
255 logger.error("AppView returned server error for new topic", {
256 operation: "POST /new-topic",
257 status: appviewRes.status,
258 });
259 }
260
261 return c.html(<p class="form-error">{errorMessage}</p>);
262 });
263}