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;