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 { 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 return ( 273 <AtProtoRecord<FeedPostRecord> 274 did={did} 275 collection="app.bsky.feed.post" 276 rkey={rkey} 277 renderer={({ record }) => ( 278 <article data-color-scheme={scheme}> 279 <strong>{record?.text ?? 'Empty post'}</strong> 280 </article> 281 )} 282 /> 283 ); 284}; 285 286const sectionHeaderStyle: React.CSSProperties = { margin: '4px 0', fontSize: 16 }; 287const loadingBox: React.CSSProperties = { padding: 8 }; 288const errorBox: React.CSSProperties = { padding: 8, color: 'crimson' }; 289const infoBox: React.CSSProperties = { padding: 8, color: '#555' }; 290 291const latestSummaryPalette = { 292 light: { 293 card: { 294 border: '1px solid #e2e8f0', 295 background: '#ffffff', 296 borderRadius: 12, 297 padding: 12, 298 display: 'flex', 299 flexDirection: 'column', 300 gap: 8 301 } satisfies React.CSSProperties, 302 header: { 303 display: 'flex', 304 alignItems: 'baseline', 305 justifyContent: 'space-between', 306 gap: 12, 307 color: '#0f172a' 308 } satisfies React.CSSProperties, 309 time: { 310 fontSize: 12, 311 color: '#64748b' 312 } satisfies React.CSSProperties, 313 text: { 314 margin: 0, 315 color: '#1f2937', 316 whiteSpace: 'pre-wrap' 317 } satisfies React.CSSProperties, 318 link: { 319 color: '#2563eb', 320 fontWeight: 600, 321 fontSize: 12, 322 textDecoration: 'none' 323 } satisfies React.CSSProperties, 324 muted: { 325 color: '#64748b' 326 } satisfies React.CSSProperties, 327 error: { 328 color: 'crimson' 329 } satisfies React.CSSProperties 330 }, 331 dark: { 332 card: { 333 border: '1px solid #1e293b', 334 background: '#0f172a', 335 borderRadius: 12, 336 padding: 12, 337 display: 'flex', 338 flexDirection: 'column', 339 gap: 8 340 } satisfies React.CSSProperties, 341 header: { 342 display: 'flex', 343 alignItems: 'baseline', 344 justifyContent: 'space-between', 345 gap: 12, 346 color: '#e2e8f0' 347 } satisfies React.CSSProperties, 348 time: { 349 fontSize: 12, 350 color: '#cbd5f5' 351 } satisfies React.CSSProperties, 352 text: { 353 margin: 0, 354 color: '#e2e8f0', 355 whiteSpace: 'pre-wrap' 356 } satisfies React.CSSProperties, 357 link: { 358 color: '#38bdf8', 359 fontWeight: 600, 360 fontSize: 12, 361 textDecoration: 'none' 362 } satisfies React.CSSProperties, 363 muted: { 364 color: '#94a3b8' 365 } satisfies React.CSSProperties, 366 error: { 367 color: '#f472b6' 368 } satisfies React.CSSProperties 369 } 370} as const; 371 372export const App: React.FC = () => { 373 return ( 374 <AtProtoProvider> 375 <div style={{ maxWidth: 860, margin: '40px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}> 376 <h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1> 377 <p style={{ lineHeight: 1.4 }}>A component library for rendering common AT Protocol records for applications such as Bluesky and Tangled.</p> 378 <hr style={{ margin: '32px 0' }} /> 379 <FullDemo /> 380 </div> 381 </AtProtoProvider> 382 ); 383}; 384 385export default App;