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 type { AppContext } from "../lib/app-context.js";
3import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db";
4import { eq, inArray, and } from "drizzle-orm";
5import { serializeBigInt, serializeDate } from "./helpers.js";
6import { handleRouteError } from "../lib/route-errors.js";
7import { parseAtUri } from "../lib/at-uri.js";
8
9type ThemeRow = typeof themes.$inferSelect;
10
11function serializeThemeSummary(theme: ThemeRow) {
12 return {
13 id: serializeBigInt(theme.id),
14 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`,
15 name: theme.name,
16 colorScheme: theme.colorScheme,
17 indexedAt: serializeDate(theme.indexedAt),
18 };
19}
20
21function serializeThemeFull(theme: ThemeRow) {
22 return {
23 id: serializeBigInt(theme.id),
24 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`,
25 cid: theme.cid,
26 name: theme.name,
27 colorScheme: theme.colorScheme,
28 tokens: theme.tokens,
29 cssOverrides: theme.cssOverrides ?? null,
30 fontUrls: (theme.fontUrls as string[] | null) ?? null,
31 createdAt: serializeDate(theme.createdAt),
32 indexedAt: serializeDate(theme.indexedAt),
33 };
34}
35
36export function createThemesRoutes(ctx: AppContext) {
37 return new Hono()
38 .get("/", async (c) => {
39 try {
40 // Step 1: Get available theme URIs from this forum's policy
41 const availableRows = await ctx.db
42 .select({ themeUri: themePolicyAvailableThemes.themeUri })
43 .from(themePolicyAvailableThemes)
44 .innerJoin(
45 themePolicies,
46 eq(themePolicies.id, themePolicyAvailableThemes.policyId)
47 )
48 .where(eq(themePolicies.did, ctx.config.forumDid));
49
50 if (availableRows.length === 0) {
51 c.header("Cache-Control", "public, max-age=300");
52 return c.json({ themes: [] });
53 }
54
55 // Step 2: Parse rkeys from AT-URIs
56 const rkeys = availableRows
57 .map((r) => parseAtUri(r.themeUri)?.rkey)
58 .filter((rkey): rkey is string => !!rkey);
59
60 if (rkeys.length === 0) {
61 c.header("Cache-Control", "public, max-age=300");
62 return c.json({ themes: [] });
63 }
64
65 // Step 3: Fetch matching themes
66 const themeList = await ctx.db
67 .select()
68 .from(themes)
69 .where(
70 and(
71 eq(themes.did, ctx.config.forumDid),
72 inArray(themes.rkey, rkeys)
73 )
74 )
75 .limit(100);
76
77 c.header("Cache-Control", "public, max-age=300");
78 return c.json({ themes: themeList.map(serializeThemeSummary) });
79 } catch (error) {
80 return handleRouteError(c, error, "Failed to retrieve themes", {
81 operation: "GET /api/themes",
82 logger: ctx.logger,
83 });
84 }
85 })
86 .get("/:rkey", async (c) => {
87 const rkey = c.req.param("rkey").trim();
88 if (!rkey) {
89 return c.json({ error: "Invalid theme rkey" }, 400);
90 }
91
92 try {
93 const [theme] = await ctx.db
94 .select()
95 .from(themes)
96 .where(
97 and(
98 eq(themes.did, ctx.config.forumDid),
99 eq(themes.rkey, rkey)
100 )
101 )
102 .limit(1);
103
104 if (!theme) {
105 return c.json({ error: "Theme not found" }, 404);
106 }
107
108 c.header("Cache-Control", "public, max-age=300");
109 c.header("ETag", `"${theme.cid}"`);
110 return c.json(serializeThemeFull(theme));
111 } catch (error) {
112 return handleRouteError(c, error, "Failed to retrieve theme", {
113 operation: "GET /api/themes/:rkey",
114 logger: ctx.logger,
115 themeRkey: rkey,
116 });
117 }
118 });
119}
120
121export function createThemePolicyRoutes(ctx: AppContext) {
122 return new Hono().get("/", async (c) => {
123 try {
124 const [policy] = await ctx.db
125 .select()
126 .from(themePolicies)
127 .where(eq(themePolicies.did, ctx.config.forumDid))
128 .limit(1);
129
130 if (!policy) {
131 return c.json({ error: "Theme policy not found" }, 404);
132 }
133
134 const available = await ctx.db
135 .select({
136 themeUri: themePolicyAvailableThemes.themeUri,
137 themeCid: themePolicyAvailableThemes.themeCid,
138 })
139 .from(themePolicyAvailableThemes)
140 .where(eq(themePolicyAvailableThemes.policyId, policy.id));
141
142 c.header("Cache-Control", "public, max-age=300");
143 return c.json({
144 defaultLightThemeUri: policy.defaultLightThemeUri,
145 defaultDarkThemeUri: policy.defaultDarkThemeUri,
146 allowUserChoice: policy.allowUserChoice,
147 availableThemes: available.map((t) => ({
148 uri: t.themeUri,
149 cid: t.themeCid,
150 })),
151 });
152 } catch (error) {
153 return handleRouteError(c, error, "Failed to retrieve theme policy", {
154 operation: "GET /api/theme-policy",
155 logger: ctx.logger,
156 });
157 }
158 });
159}