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