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

Configure Feed

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

at user-theme-preferences 300 lines 9.2 kB view raw
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}