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