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;