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 style={{ padding: 8, color: "crimson" }}>
28 Failed to load profile.
29 </div>
30 );
31 if (loading && !record) return <div 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="avatar" style={base.avatarImg} />
49 ) : (
50 <div
51 style={{ ...base.avatar, background: `var(--atproto-color-bg-elevated)` }}
52 aria-label="avatar"
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 {websiteHref && websiteLabel && (
75 <div style={{ marginTop: 12 }}>
76 <a
77 href={websiteHref}
78 target="_blank"
79 rel="noopener noreferrer"
80 style={{ ...base.link, color: `var(--atproto-color-link)` }}
81 >
82 {websiteLabel}
83 </a>
84 </div>
85 )}
86 <div style={base.bottomRow}>
87 <div style={base.bottomLeft}>
88 {record.createdAt && (
89 <div style={{ ...base.meta, color: `var(--atproto-color-text-secondary)` }}>
90 Joined {new Date(record.createdAt).toLocaleDateString()}
91 </div>
92 )}
93 <a
94 href={profileUrl}
95 target="_blank"
96 rel="noopener noreferrer"
97 style={{ ...base.link, color: `var(--atproto-color-link)` }}
98 >
99 View on Bluesky
100 </a>
101 </div>
102 <div aria-hidden>
103 <BlueskyIcon size={18} />
104 </div>
105 </div>
106 </div>
107 );
108};
109
110const base: Record<string, React.CSSProperties> = {
111 card: {
112 borderRadius: 12,
113 padding: 16,
114 fontFamily: "system-ui, sans-serif",
115 maxWidth: 480,
116 transition:
117 "background-color 180ms ease, border-color 180ms ease, color 180ms ease",
118 position: "relative",
119 },
120 header: {
121 display: "flex",
122 gap: 12,
123 marginBottom: 8,
124 },
125 avatar: {
126 width: 64,
127 height: 64,
128 borderRadius: "50%",
129 },
130 avatarImg: {
131 width: 64,
132 height: 64,
133 borderRadius: "50%",
134 objectFit: "cover",
135 },
136 display: {
137 fontSize: 20,
138 fontWeight: 600,
139 },
140 handleLine: {
141 fontSize: 13,
142 },
143 desc: {
144 whiteSpace: "pre-wrap",
145 fontSize: 14,
146 lineHeight: 1.4,
147 },
148 meta: {
149 marginTop: 0,
150 fontSize: 12,
151 },
152 pronouns: {
153 display: "inline-flex",
154 alignItems: "center",
155 gap: 4,
156 fontSize: 12,
157 fontWeight: 500,
158 borderRadius: 999,
159 padding: "2px 8px",
160 marginTop: 6,
161 },
162 link: {
163 display: "inline-flex",
164 alignItems: "center",
165 gap: 4,
166 fontSize: 12,
167 fontWeight: 600,
168 textDecoration: "none",
169 },
170 bottomRow: {
171 display: "flex",
172 alignItems: "flex-end",
173 justifyContent: "space-between",
174 marginTop: 12,
175 },
176 bottomLeft: {
177 display: "flex",
178 flexDirection: "column",
179 gap: 8,
180 },
181 iconCorner: {
182 // Removed absolute positioning, now in flex layout
183 },
184};
185
186export default BlueskyProfileRenderer;