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