A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from "react"; 2import type { TangledRepoRecord } from "../types/tangled"; 3import { useAtProto } from "../providers/AtProtoProvider"; 4import { useBacklinks } from "../hooks/useBacklinks"; 5import { useRepoLanguages } from "../hooks/useRepoLanguages"; 6 7export interface TangledRepoRendererProps { 8 record: TangledRepoRecord; 9 error?: Error; 10 loading: boolean; 11 did: string; 12 rkey: string; 13 canonicalUrl?: string; 14 showStarCount?: boolean; 15 branch?: string; 16 languages?: string[]; 17} 18 19export const TangledRepoRenderer: React.FC<TangledRepoRendererProps> = ({ 20 record, 21 error, 22 loading, 23 did, 24 rkey, 25 canonicalUrl, 26 showStarCount = true, 27 branch, 28 languages, 29}) => { 30 const { tangledBaseUrl, constellationBaseUrl } = useAtProto(); 31 32 // Construct the AT-URI for this repo record 33 const atUri = `at://${did}/sh.tangled.repo/${rkey}`; 34 35 // Fetch star backlinks 36 const { 37 count: starCount, 38 loading: starsLoading, 39 error: starsError, 40 } = useBacklinks({ 41 subject: atUri, 42 source: "sh.tangled.feed.star:subject", 43 limit: 100, 44 constellationBaseUrl, 45 enabled: showStarCount, 46 }); 47 48 // Extract knot server from record.knot (e.g., "knot.gaze.systems") 49 const knotUrl = record?.knot 50 ? record.knot.startsWith("http://") || record.knot.startsWith("https://") 51 ? new URL(record.knot).hostname 52 : record.knot 53 : undefined; 54 55 // Fetch language data from knot server only if languages not provided 56 const { 57 data: languagesData, 58 loading: languagesLoading, 59 error: languagesError, 60 } = useRepoLanguages({ 61 knot: knotUrl, 62 did, 63 repoName: record?.name, 64 branch, 65 enabled: !languages && !!knotUrl && !!record?.name, 66 }); 67 68 // Convert provided language names to the format expected by the renderer 69 const providedLanguagesData = languages 70 ? { 71 languages: languages.map((name) => ({ 72 name, 73 percentage: 0, 74 size: 0, 75 })), 76 ref: branch || "main", 77 totalFiles: 0, 78 totalSize: 0, 79 } 80 : undefined; 81 82 // Use provided languages or fetched languages 83 const finalLanguagesData = providedLanguagesData ?? languagesData; 84 85 if (error) 86 return ( 87 <div style={{ padding: 8, color: "crimson" }}> 88 Failed to load repository. 89 </div> 90 ); 91 if (loading && !record) return <div style={{ padding: 8 }}>Loading</div>; 92 93 // Construct the canonical URL: tangled.org/@[did]/[repo-name] 94 const viewUrl = 95 canonicalUrl ?? 96 `${tangledBaseUrl}/@${did}/${encodeURIComponent(record.name)}`; 97 const timestamp = new Date(record.createdAt).toLocaleString(undefined, { 98 dateStyle: "medium", 99 timeStyle: "short", 100 }); 101 102 const tangledIcon = ( 103 <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 25 25" style={{ display: "block" }}> 104 <path fill="currentColor" d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"/> 105 </svg> 106 ); 107 108 return ( 109 <div 110 style={{ 111 ...base.container, 112 background: `var(--atproto-color-bg-elevated)`, 113 borderWidth: "1px", 114 borderStyle: "solid", 115 borderColor: `var(--atproto-color-border)`, 116 color: `var(--atproto-color-text)`, 117 }} 118 > 119 {/* Header with title and icons */} 120 <div 121 style={{ 122 ...base.header, 123 background: `var(--atproto-color-bg-elevated)`, 124 }} 125 > 126 <div style={base.headerTop}> 127 <strong 128 style={{ 129 ...base.repoName, 130 color: `var(--atproto-color-text)`, 131 }} 132 > 133 {record.name} 134 </strong> 135 <div style={base.headerRight}> 136 <a 137 href={viewUrl} 138 target="_blank" 139 rel="noopener noreferrer" 140 style={{ 141 ...base.iconLink, 142 color: `var(--atproto-color-text)`, 143 }} 144 title="View on Tangled" 145 > 146 {tangledIcon} 147 </a> 148 {record.source && ( 149 <a 150 href={record.source} 151 target="_blank" 152 rel="noopener noreferrer" 153 style={{ 154 ...base.iconLink, 155 color: `var(--atproto-color-text)`, 156 }} 157 title="View source repository" 158 > 159 <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16" fill="currentColor" style={{ display: "block" }}> 160 <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> 161 </svg> 162 </a> 163 )} 164 </div> 165 </div> 166 </div> 167 168 {/* Description */} 169 {record.description && ( 170 <div 171 style={{ 172 ...base.description, 173 background: `var(--atproto-color-bg-elevated)`, 174 color: `var(--atproto-color-text-secondary)`, 175 }} 176 > 177 {record.description} 178 </div> 179 )} 180 181 {/* Languages and Stars */} 182 <div 183 style={{ 184 ...base.languageSection, 185 background: `var(--atproto-color-bg-elevated)`, 186 }} 187 > 188 {/* Languages */} 189 {finalLanguagesData && finalLanguagesData.languages.length > 0 && (() => { 190 const topLanguages = finalLanguagesData.languages 191 .filter((lang) => lang.name && (lang.percentage > 0 || finalLanguagesData.languages.every(l => l.percentage === 0))) 192 .sort((a, b) => b.percentage - a.percentage) 193 .slice(0, 2); 194 return topLanguages.length > 0 ? ( 195 <div style={base.languageTags}> 196 {topLanguages.map((lang, index) => ( 197 <span key={lang.name} style={base.languageTag}> 198 {lang.name} 199 </span> 200 ))} 201 </div> 202 ) : null; 203 })()} 204 205 {/* Right side: Stars and View on Tangled link */} 206 <div style={base.rightSection}> 207 {/* Stars */} 208 {showStarCount && ( 209 <div 210 style={{ 211 ...base.starCountContainer, 212 color: `var(--atproto-color-text-secondary)`, 213 }} 214 > 215 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ display: "block" }}> 216 <path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/> 217 </svg> 218 {starsLoading ? ( 219 <span style={base.starCount}>...</span> 220 ) : starsError ? ( 221 <span style={base.starCount}></span> 222 ) : ( 223 <span style={base.starCount}>{starCount}</span> 224 )} 225 </div> 226 )} 227 228 {/* View on Tangled link */} 229 <a 230 href={viewUrl} 231 target="_blank" 232 rel="noopener noreferrer" 233 style={{ 234 ...base.viewLink, 235 color: `var(--atproto-color-link)`, 236 }} 237 > 238 View on Tangled 239 </a> 240 </div> 241 </div> 242 </div> 243 ); 244}; 245 246const base: Record<string, React.CSSProperties> = { 247 container: { 248 fontFamily: "system-ui, sans-serif", 249 borderRadius: 6, 250 overflow: "hidden", 251 transition: 252 "background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease", 253 width: "100%", 254 }, 255 header: { 256 padding: "16px", 257 display: "flex", 258 flexDirection: "column", 259 }, 260 headerTop: { 261 display: "flex", 262 justifyContent: "space-between", 263 alignItems: "flex-start", 264 gap: 12, 265 }, 266 headerRight: { 267 display: "flex", 268 alignItems: "center", 269 gap: 8, 270 }, 271 repoName: { 272 fontFamily: 273 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace', 274 fontSize: 18, 275 fontWeight: 600, 276 wordBreak: "break-word", 277 margin: 0, 278 }, 279 iconLink: { 280 display: "flex", 281 alignItems: "center", 282 textDecoration: "none", 283 opacity: 0.7, 284 transition: "opacity 150ms ease", 285 }, 286 description: { 287 padding: "0 16px 16px 16px", 288 fontSize: 14, 289 lineHeight: 1.5, 290 }, 291 languageSection: { 292 padding: "0 16px 16px 16px", 293 display: "flex", 294 justifyContent: "space-between", 295 alignItems: "center", 296 gap: 12, 297 flexWrap: "wrap", 298 }, 299 languageTags: { 300 display: "flex", 301 gap: 8, 302 flexWrap: "wrap", 303 }, 304 languageTag: { 305 fontSize: 12, 306 fontWeight: 500, 307 padding: "4px 10px", 308 background: `var(--atproto-color-bg)`, 309 borderRadius: 12, 310 border: "1px solid var(--atproto-color-border)", 311 }, 312 rightSection: { 313 display: "flex", 314 alignItems: "center", 315 gap: 12, 316 }, 317 starCountContainer: { 318 display: "flex", 319 alignItems: "center", 320 gap: 4, 321 fontSize: 13, 322 }, 323 starCount: { 324 fontSize: 13, 325 fontWeight: 500, 326 }, 327 viewLink: { 328 fontSize: 13, 329 fontWeight: 500, 330 textDecoration: "none", 331 }, 332}; 333 334export default TangledRepoRenderer;