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 { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme'; 4import { BlueskyIcon } from '../components/BlueskyIcon'; 5 6export interface BlueskyProfileRendererProps { 7 record: ProfileRecord; 8 loading: boolean; 9 error?: Error; 10 did: string; 11 handle?: string; 12 avatarUrl?: string; 13 colorScheme?: ColorSchemePreference; 14} 15 16export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({ record, loading, error, did, handle, avatarUrl, colorScheme = 'system' }) => { 17 const scheme = useColorScheme(colorScheme); 18 19 if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load profile.</div>; 20 if (loading && !record) return <div style={{ padding: 8 }}>Loading</div>; 21 22 const palette = scheme === 'dark' ? theme.dark : theme.light; 23 const profileUrl = `https://bsky.app/profile/${encodeURIComponent(did)}`; 24 const rawWebsite = record.website?.trim(); 25 const websiteHref = rawWebsite ? (rawWebsite.match(/^https?:\/\//i) ? rawWebsite : `https://${rawWebsite}`) : undefined; 26 const websiteLabel = rawWebsite ? rawWebsite.replace(/^https?:\/\//i, '') : undefined; 27 28 return ( 29 <div style={{ ...base.card, ...palette.card }}> 30 <div style={base.header}> 31 {avatarUrl ? <img src={avatarUrl} alt="avatar" style={base.avatarImg} /> : <div style={{ ...base.avatar, ...palette.avatar }} aria-label="avatar" />} 32 <div style={{ flex: 1 }}> 33 <div style={{ ...base.display, ...palette.display }}>{record.displayName ?? handle ?? did}</div> 34 <div style={{ ...base.handleLine, ...palette.handleLine }}>@{handle ?? did}</div> 35 {record.pronouns && <div style={{ ...base.pronouns, ...palette.pronouns }}>{record.pronouns}</div>} 36 </div> 37 </div> 38 {record.description && <p style={{ ...base.desc, ...palette.desc }}>{record.description}</p>} 39 {record.createdAt && <div style={{ ...base.meta, ...palette.meta }}>Joined {new Date(record.createdAt).toLocaleDateString()}</div>} 40 <div style={base.links}> 41 {websiteHref && websiteLabel && ( 42 <a href={websiteHref} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}> 43 {websiteLabel} 44 </a> 45 )} 46 <a href={profileUrl} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}> 47 View on Bluesky 48 </a> 49 </div> 50 <div style={base.iconCorner} aria-hidden> 51 <BlueskyIcon size={18} /> 52 </div> 53 </div> 54 ); 55}; 56 57const base: Record<string, React.CSSProperties> = { 58 card: { 59 borderRadius: 12, 60 padding: 16, 61 fontFamily: 'system-ui, sans-serif', 62 maxWidth: 480, 63 transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease', 64 position: 'relative' 65 }, 66 header: { 67 display: 'flex', 68 gap: 12, 69 marginBottom: 8 70 }, 71 avatar: { 72 width: 64, 73 height: 64, 74 borderRadius: '50%' 75 }, 76 avatarImg: { 77 width: 64, 78 height: 64, 79 borderRadius: '50%', 80 objectFit: 'cover' 81 }, 82 display: { 83 fontSize: 20, 84 fontWeight: 600 85 }, 86 handleLine: { 87 fontSize: 13 88 }, 89 desc: { 90 whiteSpace: 'pre-wrap', 91 fontSize: 14, 92 lineHeight: 1.4 93 }, 94 meta: { 95 marginTop: 12, 96 fontSize: 12 97 }, 98 pronouns: { 99 display: 'inline-flex', 100 alignItems: 'center', 101 gap: 4, 102 fontSize: 12, 103 fontWeight: 500, 104 borderRadius: 999, 105 padding: '2px 8px', 106 marginTop: 6 107 }, 108 links: { 109 display: 'flex', 110 flexDirection: 'column', 111 gap: 8, 112 marginTop: 12 113 }, 114 link: { 115 display: 'inline-flex', 116 alignItems: 'center', 117 gap: 4, 118 fontSize: 12, 119 fontWeight: 600, 120 textDecoration: 'none' 121 }, 122 iconCorner: { 123 position: 'absolute', 124 right: 12, 125 bottom: 12 126 } 127}; 128 129const theme = { 130 light: { 131 card: { 132 border: '1px solid #e2e8f0', 133 background: '#ffffff', 134 color: '#0f172a' 135 }, 136 avatar: { 137 background: '#cbd5e1' 138 }, 139 display: { 140 color: '#0f172a' 141 }, 142 handleLine: { 143 color: '#64748b' 144 }, 145 desc: { 146 color: '#0f172a' 147 }, 148 meta: { 149 color: '#94a3b8' 150 }, 151 pronouns: { 152 background: '#e2e8f0', 153 color: '#1e293b' 154 }, 155 link: { 156 color: '#2563eb' 157 } 158 }, 159 dark: { 160 card: { 161 border: '1px solid #1e293b', 162 background: '#0b1120', 163 color: '#e2e8f0' 164 }, 165 avatar: { 166 background: '#1e293b' 167 }, 168 display: { 169 color: '#e2e8f0' 170 }, 171 handleLine: { 172 color: '#cbd5f5' 173 }, 174 desc: { 175 color: '#e2e8f0' 176 }, 177 meta: { 178 color: '#a5b4fc' 179 }, 180 pronouns: { 181 background: '#1e293b', 182 color: '#e2e8f0' 183 }, 184 link: { 185 color: '#38bdf8' 186 } 187 } 188} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>; 189 190export default BlueskyProfileRenderer;