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 propno 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 Reply Post Demo 329 </h3> 330 <BlueskyPost 331 did="nekomimi.pet" 332 rkey="3m36jkng6nk22" 333 showParent={true} 334 recursiveParent={true} 335 /> 336 <section /> 337 </section> 338 <section style={panelStyle}> 339 <h3 style={sectionHeaderStyle}> 340 Custom Themed Post 341 </h3> 342 <p 343 style={{ 344 fontSize: 12, 345 color: `var(--demo-text-secondary)`, 346 margin: "0 0 8px", 347 }} 348 > 349 Wrapping a component in a div with custom 350 CSS variables to override the theme! 351 </p> 352 <div 353 style={ 354 { 355 "--atproto-color-bg": 356 "var(--demo-secondary-bg)", 357 "--atproto-color-bg-elevated": 358 "var(--demo-input-bg)", 359 "--atproto-color-bg-secondary": 360 "var(--demo-code-bg)", 361 "--atproto-color-text": 362 "var(--demo-text)", 363 "--atproto-color-text-secondary": 364 "var(--demo-text-secondary)", 365 "--atproto-color-text-muted": 366 "var(--demo-text-secondary)", 367 "--atproto-color-border": 368 "var(--demo-border)", 369 "--atproto-color-border-subtle": 370 "var(--demo-border)", 371 "--atproto-color-link": 372 "var(--demo-button-bg)", 373 } as React.CSSProperties 374 } 375 > 376 <BlueskyPost 377 did="nekomimi.pet" 378 rkey="3m2dgvyws7k27" 379 /> 380 </div> 381 </section> 382 </div> 383 </div> 384 <section style={gistPanelStyle}> 385 <h3 style={sectionHeaderStyle}>A Tangled String</h3> 386 <TangledString 387 did="nekomimi.pet" 388 rkey="3m2p4gjptg522" 389 /> 390 </section> 391 <section style={leafletPanelStyle}> 392 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3> 393 <div 394 style={{ 395 width: "100%", 396 display: "flex", 397 justifyContent: "center", 398 }} 399 > 400 <LeafletDocument 401 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"} 402 rkey={"3m2seagm2222c"} 403 /> 404 </div> 405 </section> 406 </> 407 )} 408 <section style={{ ...panelStyle, marginTop: 32 }}> 409 <h3 style={sectionHeaderStyle}>Code Examples</h3> 410 <p 411 style={{ 412 color: `var(--demo-text-secondary)`, 413 margin: "4px 0 8px", 414 }} 415 > 416 Wrap your app with the provider once and drop the ready-made 417 components wherever you need them. 418 </p> 419 <pre style={codeBlockStyle}> 420 <code 421 ref={basicCodeRef} 422 className="language-tsx" 423 style={codeTextStyle} 424 > 425 {basicUsageSnippet} 426 </code> 427 </pre> 428 <p 429 style={{ 430 color: `var(--demo-text-secondary)`, 431 margin: "16px 0 8px", 432 }} 433 > 434 Pass prefetched data to components to skip API callsperfect 435 for SSR or caching. 436 </p> 437 <pre style={codeBlockStyle}> 438 <code 439 ref={customCodeRef} 440 className="language-tsx" 441 style={codeTextStyle} 442 > 443 {prefetchedDataSnippet} 444 </code> 445 </pre> 446 </section> 447 </div> 448 ); 449}; 450 451const sectionHeaderStyle: React.CSSProperties = { 452 margin: "4px 0", 453 fontSize: 16, 454 color: "var(--demo-text)", 455}; 456const loadingBox: React.CSSProperties = { padding: 8 }; 457const errorBox: React.CSSProperties = { padding: 8, color: "crimson" }; 458const infoBox: React.CSSProperties = { 459 padding: 8, 460 color: "var(--demo-text-secondary)", 461}; 462 463export const App: React.FC = () => { 464 return ( 465 <AtProtoProvider> 466 <div 467 style={{ 468 maxWidth: 860, 469 margin: "40px auto", 470 padding: "0 20px", 471 fontFamily: "system-ui, sans-serif", 472 minHeight: "100vh", 473 }} 474 > 475 <h1 style={{ marginTop: 0, color: "var(--demo-text)" }}> 476 atproto-ui Demo 477 </h1> 478 <p 479 style={{ 480 lineHeight: 1.4, 481 color: "var(--demo-text-secondary)", 482 }} 483 > 484 A component library for rendering common AT Protocol records 485 for applications such as Bluesky and Tangled. 486 </p> 487 <hr 488 style={{ margin: "32px 0", borderColor: "var(--demo-hr)" }} 489 /> 490 <FullDemo /> 491 </div> 492 </AtProtoProvider> 493 ); 494}; 495 496export default App;