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 type { FC, PropsWithChildren } from "hono/jsx";
2import { tokensToCss } from "../lib/theme.js";
3import { sanitizeCss } from "@atbb/css-sanitizer";
4import type { ResolvedTheme } from "../lib/theme-resolution.js";
5import type { WebSession } from "../lib/session.js";
6
7const NavContent: FC<{ auth?: WebSession; colorScheme: "light" | "dark" }> = ({
8 auth,
9 colorScheme,
10}) => {
11 const toggleLabel =
12 colorScheme === "light" ? "Switch to dark mode" : "Switch to light mode";
13 const toggleIcon = colorScheme === "light" ? "\u263D" : "\u2600";
14 return (
15 <>
16 <button
17 class="color-scheme-toggle"
18 onclick="toggleColorScheme()"
19 aria-label={toggleLabel}
20 title={toggleLabel}
21 >
22 {toggleIcon}
23 </button>
24 {auth?.authenticated ? (
25 <>
26 <span class="site-header__handle">{auth.handle}</span>
27 <a href="/settings" class="site-header__settings-link">
28 Settings
29 </a>
30 <form action="/logout" method="post" class="site-header__logout-form">
31 <button type="submit" class="site-header__logout-btn">
32 Log out
33 </button>
34 </form>
35 </>
36 ) : (
37 <a href="/login" class="site-header__login-link">
38 Log in
39 </a>
40 )}
41 </>
42 );
43};
44
45export const BaseLayout: FC<
46 PropsWithChildren<{
47 title?: string;
48 auth?: WebSession;
49 resolvedTheme: ResolvedTheme;
50 }>
51> = (props) => {
52 const { auth, resolvedTheme } = props;
53
54 let rootCss = "";
55 try {
56 rootCss = sanitizeCss(`:root { ${tokensToCss(resolvedTheme.tokens)} }`);
57 } catch (err) {
58 console.error("Failed to sanitize root CSS tokens — rendering without tokens", {
59 error: String(err),
60 });
61 }
62
63 let overridesCss: string | null = null;
64 if (resolvedTheme.cssOverrides) {
65 try {
66 overridesCss = sanitizeCss(resolvedTheme.cssOverrides);
67 } catch (err) {
68 console.error("Failed to sanitize CSS overrides — rendering without overrides", {
69 error: String(err),
70 });
71 }
72 }
73
74 return (
75 <html lang="en">
76 <head>
77 <meta charset="UTF-8" />
78 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
79 <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />
80 <title>{props.title ?? "atBB Forum"}</title>
81 <style dangerouslySetInnerHTML={{ __html: rootCss }} />
82 {overridesCss && (
83 <style dangerouslySetInnerHTML={{ __html: overridesCss }} />
84 )}
85 {resolvedTheme.fontUrls && resolvedTheme.fontUrls.length > 0 && (() => {
86 const safeFontUrls = resolvedTheme.fontUrls!.filter((url) => url.startsWith("https://"));
87 return safeFontUrls.length > 0 ? (
88 <>
89 <link rel="preconnect" href="https://fonts.googleapis.com" />
90 <link
91 rel="preconnect"
92 href="https://fonts.gstatic.com"
93 crossorigin="anonymous"
94 />
95 {safeFontUrls.map((url) => (
96 <link rel="stylesheet" href={url} />
97 ))}
98 </>
99 ) : null;
100 })()}
101 <link rel="stylesheet" href="/static/css/reset.css" />
102 <link rel="stylesheet" href="/static/css/theme.css" />
103 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
104 <script src="https://unpkg.com/htmx.org@2.0.4" defer />
105 </head>
106 <body>
107 <a href="#main-content" class="skip-link">
108 Skip to main content
109 </a>
110 <header class="site-header">
111 <div class="site-header__inner">
112 <a href="/" class="site-header__title">
113 atBB Forum
114 </a>
115 <nav class="desktop-nav" aria-label="Main navigation">
116 <NavContent auth={auth} colorScheme={resolvedTheme.colorScheme} />
117 </nav>
118 <details class="mobile-nav">
119 <summary class="mobile-nav__toggle" aria-label="Menu">
120 ☰
121 </summary>
122 <nav class="mobile-nav__menu" aria-label="Mobile navigation">
123 <NavContent auth={auth} colorScheme={resolvedTheme.colorScheme} />
124 </nav>
125 </details>
126 </div>
127 </header>
128 <main id="main-content" class="content-container">
129 {props.children}
130 </main>
131 <footer class="site-footer">
132 <p>Powered by atBB on the ATmosphere</p>
133 </footer>
134 <script
135 dangerouslySetInnerHTML={{
136 __html: `function toggleColorScheme(){var m=document.cookie.match(/(?:^|;\\s*)atbb-color-scheme=(light|dark)/);var current=m?m[1]:'light';var next=current==='light'?'dark':'light';document.cookie='atbb-color-scheme='+next+';path=/;max-age=31536000;SameSite=Lax';location.reload();}`,
137 }}
138 />
139 </body>
140 </html>
141 );
142};