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