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