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