A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from 'react';
2import type { ProfileRecord } from '../types/bluesky';
3import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
4import { BlueskyIcon } from '../components/BlueskyIcon';
5
6export interface BlueskyProfileRendererProps {
7 record: ProfileRecord;
8 loading: boolean;
9 error?: Error;
10 did: string;
11 handle?: string;
12 avatarUrl?: string;
13 colorScheme?: ColorSchemePreference;
14}
15
16export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({ record, loading, error, did, handle, avatarUrl, colorScheme = 'system' }) => {
17 const scheme = useColorScheme(colorScheme);
18
19 if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load profile.</div>;
20 if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
21
22 const palette = scheme === 'dark' ? theme.dark : theme.light;
23 const profileUrl = `https://bsky.app/profile/${encodeURIComponent(did)}`;
24 const rawWebsite = record.website?.trim();
25 const websiteHref = rawWebsite ? (rawWebsite.match(/^https?:\/\//i) ? rawWebsite : `https://${rawWebsite}`) : undefined;
26 const websiteLabel = rawWebsite ? rawWebsite.replace(/^https?:\/\//i, '') : undefined;
27
28 return (
29 <div style={{ ...base.card, ...palette.card }}>
30 <div style={base.header}>
31 {avatarUrl ? <img src={avatarUrl} alt="avatar" style={base.avatarImg} /> : <div style={{ ...base.avatar, ...palette.avatar }} aria-label="avatar" />}
32 <div style={{ flex: 1 }}>
33 <div style={{ ...base.display, ...palette.display }}>{record.displayName ?? handle ?? did}</div>
34 <div style={{ ...base.handleLine, ...palette.handleLine }}>@{handle ?? did}</div>
35 {record.pronouns && <div style={{ ...base.pronouns, ...palette.pronouns }}>{record.pronouns}</div>}
36 </div>
37 </div>
38 {record.description && <p style={{ ...base.desc, ...palette.desc }}>{record.description}</p>}
39 {record.createdAt && <div style={{ ...base.meta, ...palette.meta }}>Joined {new Date(record.createdAt).toLocaleDateString()}</div>}
40 <div style={base.links}>
41 {websiteHref && websiteLabel && (
42 <a href={websiteHref} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}>
43 {websiteLabel}
44 </a>
45 )}
46 <a href={profileUrl} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}>
47 View on Bluesky
48 </a>
49 </div>
50 <div style={base.iconCorner} aria-hidden>
51 <BlueskyIcon size={18} />
52 </div>
53 </div>
54 );
55};
56
57const base: Record<string, React.CSSProperties> = {
58 card: {
59 borderRadius: 12,
60 padding: 16,
61 fontFamily: 'system-ui, sans-serif',
62 maxWidth: 480,
63 transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease',
64 position: 'relative'
65 },
66 header: {
67 display: 'flex',
68 gap: 12,
69 marginBottom: 8
70 },
71 avatar: {
72 width: 64,
73 height: 64,
74 borderRadius: '50%'
75 },
76 avatarImg: {
77 width: 64,
78 height: 64,
79 borderRadius: '50%',
80 objectFit: 'cover'
81 },
82 display: {
83 fontSize: 20,
84 fontWeight: 600
85 },
86 handleLine: {
87 fontSize: 13
88 },
89 desc: {
90 whiteSpace: 'pre-wrap',
91 fontSize: 14,
92 lineHeight: 1.4
93 },
94 meta: {
95 marginTop: 12,
96 fontSize: 12
97 },
98 pronouns: {
99 display: 'inline-flex',
100 alignItems: 'center',
101 gap: 4,
102 fontSize: 12,
103 fontWeight: 500,
104 borderRadius: 999,
105 padding: '2px 8px',
106 marginTop: 6
107 },
108 links: {
109 display: 'flex',
110 flexDirection: 'column',
111 gap: 8,
112 marginTop: 12
113 },
114 link: {
115 display: 'inline-flex',
116 alignItems: 'center',
117 gap: 4,
118 fontSize: 12,
119 fontWeight: 600,
120 textDecoration: 'none'
121 },
122 iconCorner: {
123 position: 'absolute',
124 right: 12,
125 bottom: 12
126 }
127};
128
129const theme = {
130 light: {
131 card: {
132 border: '1px solid #e2e8f0',
133 background: '#ffffff',
134 color: '#0f172a'
135 },
136 avatar: {
137 background: '#cbd5e1'
138 },
139 display: {
140 color: '#0f172a'
141 },
142 handleLine: {
143 color: '#64748b'
144 },
145 desc: {
146 color: '#0f172a'
147 },
148 meta: {
149 color: '#94a3b8'
150 },
151 pronouns: {
152 background: '#e2e8f0',
153 color: '#1e293b'
154 },
155 link: {
156 color: '#2563eb'
157 }
158 },
159 dark: {
160 card: {
161 border: '1px solid #1e293b',
162 background: '#0b1120',
163 color: '#e2e8f0'
164 },
165 avatar: {
166 background: '#1e293b'
167 },
168 display: {
169 color: '#e2e8f0'
170 },
171 handleLine: {
172 color: '#cbd5f5'
173 },
174 desc: {
175 color: '#e2e8f0'
176 },
177 meta: {
178 color: '#a5b4fc'
179 },
180 pronouns: {
181 background: '#1e293b',
182 color: '#e2e8f0'
183 },
184 link: {
185 color: '#38bdf8'
186 }
187 }
188} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
189
190export default BlueskyProfileRenderer;