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