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;