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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 263 lines 9.4 kB view raw
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}