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