A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; 2import { AtProtoProvider } from '../lib/providers/AtProtoProvider'; 3import { AtProtoRecord } from '../lib/core/AtProtoRecord'; 4import { TangledString } from '../lib/components/TangledString'; 5import { LeafletDocument } from '../lib/components/LeafletDocument'; 6import { BlueskyProfile } from '../lib/components/BlueskyProfile'; 7import { BlueskyPost, BLUESKY_POST_COLLECTION } from '../lib/components/BlueskyPost'; 8import { BlueskyPostList } from '../lib/components/BlueskyPostList'; 9import { BlueskyQuotePost } from '../lib/components/BlueskyQuotePost'; 10import { useDidResolution } from '../lib/hooks/useDidResolution'; 11import { useLatestRecord } from '../lib/hooks/useLatestRecord'; 12import { ColorSchemeToggle } from '../lib/components/ColorSchemeToggle.tsx'; 13import { useColorScheme, type ColorSchemePreference } from '../lib/hooks/useColorScheme'; 14import type { FeedPostRecord } from '../lib/types/bluesky'; 15 16const COLOR_SCHEME_STORAGE_KEY = 'atproto-ui-color-scheme'; 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 customComponentSnippet = `import { useLatestRecord, useColorScheme, AtProtoRecord } from 'atproto-ui'; 29import type { FeedPostRecord } from 'atproto-ui'; 30 31const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => { 32 const scheme = useColorScheme('system'); 33 const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post'); 34 35 if (loading) return <span>Loading…</span>; 36 if (error || !rkey) return <span>No post yet.</span>; 37 38 return ( 39 <AtProtoRecord<FeedPostRecord> 40 did={did} 41 collection="app.bsky.feed.post" 42 rkey={rkey} 43 renderer={({ record }) => ( 44 <article data-color-scheme={scheme}> 45 <strong>{record?.text ?? 'Empty post'}</strong> 46 </article> 47 )} 48 /> 49 ); 50};`; 51 52const codeBlockBase: React.CSSProperties = { 53 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace', 54 fontSize: 12, 55 whiteSpace: 'pre', 56 overflowX: 'auto', 57 borderRadius: 10, 58 padding: '12px 14px', 59 lineHeight: 1.6 60}; 61 62const FullDemo: React.FC = () => { 63 const handleInputRef = useRef<HTMLInputElement | null>(null); 64 const [submitted, setSubmitted] = useState<string | null>(null); 65 const [colorSchemePreference, setColorSchemePreference] = useState<ColorSchemePreference>(() => { 66 if (typeof window === 'undefined') return 'system'; 67 try { 68 const stored = window.localStorage.getItem(COLOR_SCHEME_STORAGE_KEY); 69 if (stored === 'light' || stored === 'dark' || stored === 'system') return stored; 70 } catch { 71 /* ignore */ 72 } 73 return 'system'; 74 }); 75 const scheme = useColorScheme(colorSchemePreference); 76 const { did, loading: resolvingDid } = useDidResolution(submitted ?? undefined); 77 const onSubmit = useCallback<React.FormEventHandler>((e) => { 78 e.preventDefault(); 79 const rawValue = handleInputRef.current?.value; 80 const nextValue = rawValue?.trim(); 81 if (!nextValue) return; 82 if (handleInputRef.current) { 83 handleInputRef.current.value = nextValue; 84 } 85 setSubmitted(nextValue); 86 }, []); 87 88 useEffect(() => { 89 if (typeof window === 'undefined') return; 90 try { 91 window.localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, colorSchemePreference); 92 } catch { 93 /* ignore */ 94 } 95 }, [colorSchemePreference]); 96 97 useEffect(() => { 98 if (typeof document === 'undefined') return; 99 const root = document.documentElement; 100 const body = document.body; 101 const prevScheme = root.dataset.colorScheme; 102 const prevBg = body.style.backgroundColor; 103 const prevColor = body.style.color; 104 root.dataset.colorScheme = scheme; 105 body.style.backgroundColor = scheme === 'dark' ? '#020617' : '#f8fafc'; 106 body.style.color = scheme === 'dark' ? '#e2e8f0' : '#0f172a'; 107 return () => { 108 root.dataset.colorScheme = prevScheme ?? ''; 109 body.style.backgroundColor = prevBg; 110 body.style.color = prevColor; 111 }; 112 }, [scheme]); 113 114 const showHandle = submitted && !submitted.startsWith('did:') ? submitted : undefined; 115 116 const mutedTextColor = useMemo(() => (scheme === 'dark' ? '#94a3b8' : '#555'), [scheme]); 117 const panelStyle = useMemo<React.CSSProperties>(() => ({ 118 display: 'flex', 119 flexDirection: 'column', 120 gap: 8, 121 padding: 10, 122 borderRadius: 12, 123 borderColor: scheme === 'dark' ? '#1e293b' : '#e2e8f0', 124 }), [scheme]); 125 const baseTextColor = useMemo(() => (scheme === 'dark' ? '#e2e8f0' : '#0f172a'), [scheme]); 126 const gistPanelStyle = useMemo<React.CSSProperties>(() => ({ 127 ...panelStyle, 128 padding: 0, 129 border: 'none', 130 background: 'transparent', 131 backdropFilter: 'none', 132 marginTop: 32 133 }), [panelStyle]); 134 const leafletPanelStyle = useMemo<React.CSSProperties>(() => ({ 135 ...panelStyle, 136 padding: 0, 137 border: 'none', 138 background: 'transparent', 139 backdropFilter: 'none', 140 marginTop: 32, 141 alignItems: 'center' 142 }), [panelStyle]); 143 const primaryGridStyle = useMemo<React.CSSProperties>(() => ({ 144 display: 'grid', 145 gap: 32, 146 gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' 147 }), []); 148 const columnStackStyle = useMemo<React.CSSProperties>(() => ({ 149 display: 'flex', 150 flexDirection: 'column', 151 gap: 32 152 }), []); 153 const codeBlockStyle = useMemo<React.CSSProperties>(() => ({ 154 ...codeBlockBase, 155 background: scheme === 'dark' ? '#0b1120' : '#f1f5f9', 156 border: `1px solid ${scheme === 'dark' ? '#1e293b' : '#e2e8f0'}` 157 }), [scheme]); 158 const codeTextStyle = useMemo<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 170 const { 171 rkey: latestPostRkey, 172 loading: loadingLatestPost, 173 empty: noPosts, 174 error: latestPostError 175 } = useLatestRecord<unknown>(did, BLUESKY_POST_COLLECTION); 176 177 const quoteSampleDid = 'did:plc:ttdrpj45ibqunmfhdsb4zdwq'; 178 const quoteSampleRkey = '3m2prlq6xxc2v'; 179 180 return ( 181 <div style={{ display: 'flex', flexDirection: 'column', gap: 20, color: baseTextColor }}> 182 <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between' }}> 183 <form onSubmit={onSubmit} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', flex: '1 1 320px' }}> 184 <input 185 placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)" 186 ref={handleInputRef} 187 style={{ flex: '1 1 260px', padding: '6px 8px', borderRadius: 8, border: '1px solid', borderColor: scheme === 'dark' ? '#1e293b' : '#cbd5f5', background: scheme === 'dark' ? '#0b1120' : '#fff', color: scheme === 'dark' ? '#e2e8f0' : '#0f172a' }} 188 /> 189 <button type="submit" style={{ padding: '6px 16px', borderRadius: 8, border: 'none', background: '#2563eb', color: '#fff', cursor: 'pointer' }}>Load</button> 190 </form> 191 <ColorSchemeToggle value={colorSchemePreference} onChange={setColorSchemePreference} scheme={scheme} /> 192 </div> 193 {!submitted && <p style={{ color: mutedTextColor }}>Enter a handle to fetch your profile, latest Bluesky post, a Tangled string, and a Leaflet document.</p>} 194 {submitted && resolvingDid && <p style={{ color: mutedTextColor }}>Resolving DID</p>} 195 {did && ( 196 <> 197 <div style={primaryGridStyle}> 198 <div style={columnStackStyle}> 199 <section style={panelStyle}> 200 <h3 style={sectionHeaderStyle}>Profile</h3> 201 <BlueskyProfile did={did} handle={showHandle} colorScheme={colorSchemePreference} /> 202 </section> 203 <section style={panelStyle}> 204 <h3 style={sectionHeaderStyle}>Recent Posts</h3> 205 <BlueskyPostList did={did} colorScheme={colorSchemePreference} /> 206 </section> 207 </div> 208 <div style={columnStackStyle}> 209 <section style={panelStyle}> 210 <h3 style={sectionHeaderStyle}>Latest Bluesky Post</h3> 211 {loadingLatestPost && <div style={loadingBox}>Loading latest post</div>} 212 {latestPostError && <div style={errorBox}>Failed to load latest post.</div>} 213 {noPosts && <div style={{ ...infoBox, color: mutedTextColor }}>No posts found.</div>} 214 {!loadingLatestPost && latestPostRkey && ( 215 <BlueskyPost did={did} rkey={latestPostRkey} colorScheme={colorSchemePreference} /> 216 )} 217 </section> 218 <section style={panelStyle}> 219 <h3 style={sectionHeaderStyle}>Quote Post Demo</h3> 220 <BlueskyQuotePost did={quoteSampleDid} rkey={quoteSampleRkey} colorScheme={colorSchemePreference} /> 221 </section> 222 </div> 223 </div> 224 <section style={gistPanelStyle}> 225 <h3 style={sectionHeaderStyle}>A Tangled String</h3> 226 <TangledString did="nekomimi.pet" rkey="3m2p4gjptg522" colorScheme={colorSchemePreference} /> 227 </section> 228 <section style={leafletPanelStyle}> 229 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3> 230 <div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}> 231 <LeafletDocument did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"} rkey={"3m2seagm2222c"} colorScheme={colorSchemePreference} /> 232 </div> 233 </section> 234 </> 235 )} 236 <section style={{ ...panelStyle, marginTop: 32 }}> 237 <h3 style={sectionHeaderStyle}>Build your own component</h3> 238 <p style={{ color: mutedTextColor, margin: '4px 0 8px' }}> 239 Wrap your app with the provider once and drop the ready-made components wherever you need them. 240 </p> 241 <pre style={codeBlockStyle}> 242 <code ref={basicCodeRef} className="language-tsx" style={codeTextStyle}>{basicUsageSnippet}</code> 243 </pre> 244 <p style={{ color: mutedTextColor, margin: '16px 0 8px' }}> 245 Need to make your own component? Compose your own renderer with the hooks and utilities that ship with the library. 246 </p> 247 <pre style={codeBlockStyle}> 248 <code ref={customCodeRef} className="language-tsx" style={codeTextStyle}>{customComponentSnippet}</code> 249 </pre> 250 {did && ( 251 <div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}> 252 <p style={{ color: mutedTextColor, margin: 0 }}> 253 Live example with your handle: 254 </p> 255 <LatestPostSummary did={did} handle={showHandle} colorScheme={colorSchemePreference} /> 256 </div> 257 )} 258 </section> 259 </div> 260 ); 261}; 262 263const LatestPostSummary: React.FC<{ did: string; handle?: string; colorScheme: ColorSchemePreference }> = ({ did, colorScheme }) => { 264 const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION); 265 const scheme = useColorScheme(colorScheme); 266 const palette = scheme === 'dark' ? latestSummaryPalette.dark : latestSummaryPalette.light; 267 268 if (loading) return <div style={palette.muted}>Loading summary</div>; 269 if (error) return <div style={palette.error}>Failed to load the latest post.</div>; 270 if (!rkey) return <div style={palette.muted}>No posts published yet.</div>; 271 272 const atProtoProps = record 273 ? { record } 274 : { did, collection: 'app.bsky.feed.post', rkey }; 275 276 return ( 277 <AtProtoRecord<FeedPostRecord> 278 {...atProtoProps} 279 renderer={({ record: resolvedRecord }) => ( 280 <article data-color-scheme={scheme}> 281 <strong>{resolvedRecord?.text ?? 'Empty post'}</strong> 282 </article> 283 )} 284 /> 285 ); 286}; 287 288const sectionHeaderStyle: React.CSSProperties = { margin: '4px 0', fontSize: 16 }; 289const loadingBox: React.CSSProperties = { padding: 8 }; 290const errorBox: React.CSSProperties = { padding: 8, color: 'crimson' }; 291const infoBox: React.CSSProperties = { padding: 8, color: '#555' }; 292 293const latestSummaryPalette = { 294 light: { 295 card: { 296 border: '1px solid #e2e8f0', 297 background: '#ffffff', 298 borderRadius: 12, 299 padding: 12, 300 display: 'flex', 301 flexDirection: 'column', 302 gap: 8 303 } satisfies React.CSSProperties, 304 header: { 305 display: 'flex', 306 alignItems: 'baseline', 307 justifyContent: 'space-between', 308 gap: 12, 309 color: '#0f172a' 310 } satisfies React.CSSProperties, 311 time: { 312 fontSize: 12, 313 color: '#64748b' 314 } satisfies React.CSSProperties, 315 text: { 316 margin: 0, 317 color: '#1f2937', 318 whiteSpace: 'pre-wrap' 319 } satisfies React.CSSProperties, 320 link: { 321 color: '#2563eb', 322 fontWeight: 600, 323 fontSize: 12, 324 textDecoration: 'none' 325 } satisfies React.CSSProperties, 326 muted: { 327 color: '#64748b' 328 } satisfies React.CSSProperties, 329 error: { 330 color: 'crimson' 331 } satisfies React.CSSProperties 332 }, 333 dark: { 334 card: { 335 border: '1px solid #1e293b', 336 background: '#0f172a', 337 borderRadius: 12, 338 padding: 12, 339 display: 'flex', 340 flexDirection: 'column', 341 gap: 8 342 } satisfies React.CSSProperties, 343 header: { 344 display: 'flex', 345 alignItems: 'baseline', 346 justifyContent: 'space-between', 347 gap: 12, 348 color: '#e2e8f0' 349 } satisfies React.CSSProperties, 350 time: { 351 fontSize: 12, 352 color: '#cbd5f5' 353 } satisfies React.CSSProperties, 354 text: { 355 margin: 0, 356 color: '#e2e8f0', 357 whiteSpace: 'pre-wrap' 358 } satisfies React.CSSProperties, 359 link: { 360 color: '#38bdf8', 361 fontWeight: 600, 362 fontSize: 12, 363 textDecoration: 'none' 364 } satisfies React.CSSProperties, 365 muted: { 366 color: '#94a3b8' 367 } satisfies React.CSSProperties, 368 error: { 369 color: '#f472b6' 370 } satisfies React.CSSProperties 371 } 372} as const; 373 374export const App: React.FC = () => { 375 return ( 376 <AtProtoProvider> 377 <div style={{ maxWidth: 860, margin: '40px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}> 378 <h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1> 379 <p style={{ lineHeight: 1.4 }}>A component library for rendering common AT Protocol records for applications such as Bluesky and Tangled.</p> 380 <hr style={{ margin: '32px 0' }} /> 381 <FullDemo /> 382 </div> 383 </AtProtoProvider> 384 ); 385}; 386 387export default App;