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;