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