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 propno 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 callsperfect 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;