A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, {
2 useState,
3 useCallback,
4 useEffect,
5 useMemo,
6 useRef,
7} from "react";
8import { AtProtoProvider } from "../lib/providers/AtProtoProvider";
9
10import { TangledString } from "../lib/components/TangledString";
11import { LeafletDocument } from "../lib/components/LeafletDocument";
12import { BlueskyProfile } from "../lib/components/BlueskyProfile";
13import {
14 BlueskyPost,
15 BLUESKY_POST_COLLECTION,
16} from "../lib/components/BlueskyPost";
17import { BlueskyPostList } from "../lib/components/BlueskyPostList";
18import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
19import { useDidResolution } from "../lib/hooks/useDidResolution";
20import { useLatestRecord } from "../lib/hooks/useLatestRecord";
21import { ColorSchemeToggle } from "../lib/components/ColorSchemeToggle.tsx";
22import {
23 useColorScheme,
24 type ColorSchemePreference,
25} from "../lib/hooks/useColorScheme";
26import type { FeedPostRecord } from "../lib/types/bluesky";
27
28const COLOR_SCHEME_STORAGE_KEY = "atproto-ui-color-scheme";
29
30const basicUsageSnippet = `import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
31
32export function App() {
33 return (
34 <AtProtoProvider>
35 <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
36 </AtProtoProvider>
37 );
38}`;
39
40const prefetchedDataSnippet = `import { BlueskyPost, useLatestRecord } from 'atproto-ui';
41import type { FeedPostRecord } from 'atproto-ui';
42
43const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => {
44 // Fetch once with the hook
45 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
46 did,
47 'app.bsky.feed.post'
48 );
49
50 if (loading) return <span>Loading…</span>;
51 if (!record || !rkey) return <span>No posts yet.</span>;
52
53 // Pass prefetched record—BlueskyPost won't re-fetch it
54 return <BlueskyPost did={did} rkey={rkey} record={record} />;
55};`;
56
57const codeBlockBase: React.CSSProperties = {
58 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
59 fontSize: 12,
60 whiteSpace: "pre",
61 overflowX: "auto",
62 borderRadius: 10,
63 padding: "12px 14px",
64 lineHeight: 1.6,
65};
66
67const FullDemo: React.FC = () => {
68 const handleInputRef = useRef<HTMLInputElement | null>(null);
69 const [submitted, setSubmitted] = useState<string | null>(null);
70 const [colorSchemePreference, setColorSchemePreference] =
71 useState<ColorSchemePreference>(() => {
72 if (typeof window === "undefined") return "system";
73 try {
74 const stored = window.localStorage.getItem(
75 COLOR_SCHEME_STORAGE_KEY,
76 );
77 if (
78 stored === "light" ||
79 stored === "dark" ||
80 stored === "system"
81 )
82 return stored;
83 } catch {
84 /* ignore */
85 }
86 return "system";
87 });
88 const scheme = useColorScheme(colorSchemePreference);
89 const { did, loading: resolvingDid } = useDidResolution(
90 submitted ?? undefined,
91 );
92 const onSubmit = useCallback<React.FormEventHandler>((e) => {
93 e.preventDefault();
94 const rawValue = handleInputRef.current?.value;
95 const nextValue = rawValue?.trim();
96 if (!nextValue) return;
97 if (handleInputRef.current) {
98 handleInputRef.current.value = nextValue;
99 }
100 setSubmitted(nextValue);
101 }, []);
102
103 useEffect(() => {
104 if (typeof window === "undefined") return;
105 try {
106 window.localStorage.setItem(
107 COLOR_SCHEME_STORAGE_KEY,
108 colorSchemePreference,
109 );
110 } catch {
111 /* ignore */
112 }
113 }, [colorSchemePreference]);
114
115 useEffect(() => {
116 if (typeof document === "undefined") return;
117 const root = document.documentElement;
118 const body = document.body;
119 const prevScheme = root.dataset.colorScheme;
120 const prevBg = body.style.backgroundColor;
121 const prevColor = body.style.color;
122 root.dataset.colorScheme = scheme;
123 body.style.backgroundColor = scheme === "dark" ? "#020617" : "#f8fafc";
124 body.style.color = scheme === "dark" ? "#e2e8f0" : "#0f172a";
125 return () => {
126 root.dataset.colorScheme = prevScheme ?? "";
127 body.style.backgroundColor = prevBg;
128 body.style.color = prevColor;
129 };
130 }, [scheme]);
131
132 const showHandle =
133 submitted && !submitted.startsWith("did:") ? submitted : undefined;
134
135 const mutedTextColor = useMemo(
136 () => (scheme === "dark" ? "#94a3b8" : "#555"),
137 [scheme],
138 );
139 const panelStyle = useMemo<React.CSSProperties>(
140 () => ({
141 display: "flex",
142 flexDirection: "column",
143 gap: 8,
144 padding: 10,
145 borderRadius: 12,
146 borderColor: scheme === "dark" ? "#1e293b" : "#e2e8f0",
147 }),
148 [scheme],
149 );
150 const baseTextColor = useMemo(
151 () => (scheme === "dark" ? "#e2e8f0" : "#0f172a"),
152 [scheme],
153 );
154 const gistPanelStyle = useMemo<React.CSSProperties>(
155 () => ({
156 ...panelStyle,
157 padding: 0,
158 border: "none",
159 background: "transparent",
160 backdropFilter: "none",
161 marginTop: 32,
162 }),
163 [panelStyle],
164 );
165 const leafletPanelStyle = useMemo<React.CSSProperties>(
166 () => ({
167 ...panelStyle,
168 padding: 0,
169 border: "none",
170 background: "transparent",
171 backdropFilter: "none",
172 marginTop: 32,
173 alignItems: "center",
174 }),
175 [panelStyle],
176 );
177 const primaryGridStyle = useMemo<React.CSSProperties>(
178 () => ({
179 display: "grid",
180 gap: 32,
181 gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
182 }),
183 [],
184 );
185 const columnStackStyle = useMemo<React.CSSProperties>(
186 () => ({
187 display: "flex",
188 flexDirection: "column",
189 gap: 32,
190 }),
191 [],
192 );
193 const codeBlockStyle = useMemo<React.CSSProperties>(
194 () => ({
195 ...codeBlockBase,
196 background: scheme === "dark" ? "#0b1120" : "#f1f5f9",
197 border: `1px solid ${scheme === "dark" ? "#1e293b" : "#e2e8f0"}`,
198 }),
199 [scheme],
200 );
201 const codeTextStyle = useMemo<React.CSSProperties>(
202 () => ({
203 margin: 0,
204 display: "block",
205 fontFamily: codeBlockBase.fontFamily,
206 fontSize: 12,
207 lineHeight: 1.6,
208 whiteSpace: "pre",
209 }),
210 [],
211 );
212 const basicCodeRef = useRef<HTMLElement | null>(null);
213 const customCodeRef = useRef<HTMLElement | null>(null);
214
215 // Latest Bluesky post - fetch with record for prefetch demo
216 const {
217 record: latestPostRecord,
218 rkey: latestPostRkey,
219 loading: loadingLatestPost,
220 empty: noPosts,
221 error: latestPostError,
222 } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION);
223
224 const quoteSampleDid = "did:plc:ttdrpj45ibqunmfhdsb4zdwq";
225 const quoteSampleRkey = "3m2prlq6xxc2v";
226
227 return (
228 <div
229 style={{
230 display: "flex",
231 flexDirection: "column",
232 gap: 20,
233 color: baseTextColor,
234 }}
235 >
236 <div
237 style={{
238 display: "flex",
239 flexWrap: "wrap",
240 gap: 12,
241 alignItems: "center",
242 justifyContent: "space-between",
243 }}
244 >
245 <form
246 onSubmit={onSubmit}
247 style={{
248 display: "flex",
249 gap: 8,
250 flexWrap: "wrap",
251 flex: "1 1 320px",
252 }}
253 >
254 <input
255 placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
256 ref={handleInputRef}
257 style={{
258 flex: "1 1 260px",
259 padding: "6px 8px",
260 borderRadius: 8,
261 border: "1px solid",
262 borderColor:
263 scheme === "dark" ? "#1e293b" : "#cbd5f5",
264 background: scheme === "dark" ? "#0b1120" : "#fff",
265 color: scheme === "dark" ? "#e2e8f0" : "#0f172a",
266 }}
267 />
268 <button
269 type="submit"
270 style={{
271 padding: "6px 16px",
272 borderRadius: 8,
273 border: "none",
274 background: "#2563eb",
275 color: "#fff",
276 cursor: "pointer",
277 }}
278 >
279 Load
280 </button>
281 </form>
282 <ColorSchemeToggle
283 value={colorSchemePreference}
284 onChange={setColorSchemePreference}
285 scheme={scheme}
286 />
287 </div>
288 {!submitted && (
289 <p style={{ color: mutedTextColor }}>
290 Enter a handle to fetch your profile, latest Bluesky post, a
291 Tangled string, and a Leaflet document.
292 </p>
293 )}
294 {submitted && resolvingDid && (
295 <p style={{ color: mutedTextColor }}>Resolving DID…</p>
296 )}
297 {did && (
298 <>
299 <div style={primaryGridStyle}>
300 <div style={columnStackStyle}>
301 <section style={panelStyle}>
302 <h3 style={sectionHeaderStyle}>Profile</h3>
303 <BlueskyProfile
304 did={did}
305 handle={showHandle}
306 colorScheme={colorSchemePreference}
307 />
308 </section>
309 <section style={panelStyle}>
310 <h3 style={sectionHeaderStyle}>Recent Posts</h3>
311 <BlueskyPostList
312 did={did}
313 colorScheme={colorSchemePreference}
314 />
315 </section>
316 </div>
317 <div style={columnStackStyle}>
318 <section style={panelStyle}>
319 <h3 style={sectionHeaderStyle}>
320 Latest Post (Prefetched Data)
321 </h3>
322 <p style={{ fontSize: 12, color: mutedTextColor, margin: "0 0 8px" }}>
323 Using <code style={{ background: scheme === "dark" ? "#1e293b" : "#e2e8f0", padding: "2px 4px", borderRadius: 3 }}>useLatestRecord</code> to fetch once, then passing <code style={{ background: scheme === "dark" ? "#1e293b" : "#e2e8f0", padding: "2px 4px", borderRadius: 3 }}>record</code> prop—no re-fetch!
324 </p>
325 {loadingLatestPost && (
326 <div style={loadingBox}>
327 Loading latest post…
328 </div>
329 )}
330 {latestPostError && (
331 <div style={errorBox}>
332 Failed to load latest post.
333 </div>
334 )}
335 {noPosts && (
336 <div
337 style={{
338 ...infoBox,
339 color: mutedTextColor,
340 }}
341 >
342 No posts found.
343 </div>
344 )}
345 {!loadingLatestPost && latestPostRkey && latestPostRecord && (
346 <BlueskyPost
347 did={did}
348 rkey={latestPostRkey}
349 record={latestPostRecord}
350 colorScheme={colorSchemePreference}
351 />
352 )}
353 </section>
354 <section style={panelStyle}>
355 <h3 style={sectionHeaderStyle}>
356 Quote Post Demo
357 </h3>
358 <BlueskyQuotePost
359 did={quoteSampleDid}
360 rkey={quoteSampleRkey}
361 colorScheme={colorSchemePreference}
362 />
363 </section>
364 </div>
365 </div>
366 <section style={gistPanelStyle}>
367 <h3 style={sectionHeaderStyle}>A Tangled String</h3>
368 <TangledString
369 did="nekomimi.pet"
370 rkey="3m2p4gjptg522"
371 colorScheme={colorSchemePreference}
372 />
373 </section>
374 <section style={leafletPanelStyle}>
375 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
376 <div
377 style={{
378 width: "100%",
379 display: "flex",
380 justifyContent: "center",
381 }}
382 >
383 <LeafletDocument
384 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"}
385 rkey={"3m2seagm2222c"}
386 colorScheme={colorSchemePreference}
387 />
388 </div>
389 </section>
390 </>
391 )}
392 <section style={{ ...panelStyle, marginTop: 32 }}>
393 <h3 style={sectionHeaderStyle}>Code Examples</h3>
394 <p style={{ color: mutedTextColor, margin: "4px 0 8px" }}>
395 Wrap your app with the provider once and drop the ready-made
396 components wherever you need them.
397 </p>
398 <pre style={codeBlockStyle}>
399 <code
400 ref={basicCodeRef}
401 className="language-tsx"
402 style={codeTextStyle}
403 >
404 {basicUsageSnippet}
405 </code>
406 </pre>
407 <p style={{ color: mutedTextColor, margin: "16px 0 8px" }}>
408 Pass prefetched data to components to skip API calls—perfect for SSR or caching.
409 </p>
410 <pre style={codeBlockStyle}>
411 <code
412 ref={customCodeRef}
413 className="language-tsx"
414 style={codeTextStyle}
415 >
416 {prefetchedDataSnippet}
417 </code>
418 </pre>
419 </section>
420 </div>
421 );
422};
423
424
425
426const sectionHeaderStyle: React.CSSProperties = {
427 margin: "4px 0",
428 fontSize: 16,
429};
430const loadingBox: React.CSSProperties = { padding: 8 };
431const errorBox: React.CSSProperties = { padding: 8, color: "crimson" };
432const infoBox: React.CSSProperties = { padding: 8, color: "#555" };
433
434export const App: React.FC = () => {
435 return (
436 <AtProtoProvider>
437 <div
438 style={{
439 maxWidth: 860,
440 margin: "40px auto",
441 padding: "0 20px",
442 fontFamily: "system-ui, sans-serif",
443 }}
444 >
445 <h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1>
446 <p style={{ lineHeight: 1.4 }}>
447 A component library for rendering common AT Protocol records
448 for applications such as Bluesky and Tangled.
449 </p>
450 <hr style={{ margin: "32px 0" }} />
451 <FullDemo />
452 </div>
453 </AtProtoProvider>
454 );
455};
456
457export default App;