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