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;