A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 13 kB view raw
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 role="alert" style={{ padding: 8, color: "crimson" }}> 88 Failed to load repository. 89 </div> 90 ); 91 if (loading && !record) return <div role="status" aria-live="polite" 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 98 const tangledIcon = ( 99 <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 25 25" style={{ display: "block" }}> 100 <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"/> 101 </svg> 102 ); 103 104 return ( 105 <div 106 style={{ 107 ...base.container, 108 background: `var(--atproto-color-bg)`, 109 borderWidth: "1px", 110 borderStyle: "solid", 111 borderColor: `var(--atproto-color-border)`, 112 color: `var(--atproto-color-text)`, 113 }} 114 > 115 {/* Header with title and icons */} 116 <div 117 style={{ 118 ...base.header, 119 background: `var(--atproto-color-bg)`, 120 }} 121 > 122 <div style={base.headerTop}> 123 <strong 124 style={{ 125 ...base.repoName, 126 color: `var(--atproto-color-text)`, 127 }} 128 > 129 {record.name} 130 </strong> 131 <div style={base.headerRight}> 132 <a 133 href={viewUrl} 134 target="_blank" 135 rel="noopener noreferrer" 136 style={{ 137 ...base.iconLink, 138 color: `var(--atproto-color-text)`, 139 }} 140 title="View on Tangled" 141 > 142 {tangledIcon} 143 </a> 144 {record.source && ( 145 <a 146 href={record.source} 147 target="_blank" 148 rel="noopener noreferrer" 149 style={{ 150 ...base.iconLink, 151 color: `var(--atproto-color-text)`, 152 }} 153 title="View source repository" 154 > 155 <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16" fill="currentColor" style={{ display: "block" }}> 156 <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"/> 157 </svg> 158 </a> 159 )} 160 </div> 161 </div> 162 </div> 163 164 {/* Description */} 165 {record.description && ( 166 <div 167 style={{ 168 ...base.description, 169 background: `var(--atproto-color-bg)`, 170 color: `var(--atproto-color-text-secondary)`, 171 }} 172 > 173 {record.description} 174 </div> 175 )} 176 177 {/* Languages and Stars */} 178 <div 179 style={{ 180 ...base.languageSection, 181 background: `var(--atproto-color-bg)`, 182 }} 183 > 184 {/* Languages */} 185 {finalLanguagesData && finalLanguagesData.languages.length > 0 && (() => { 186 const topLanguages = finalLanguagesData.languages 187 .filter((lang) => lang.name && (lang.percentage > 0 || finalLanguagesData.languages.every(l => l.percentage === 0))) 188 .sort((a, b) => b.percentage - a.percentage) 189 .slice(0, 2); 190 return topLanguages.length > 0 ? ( 191 <div style={base.languageTags}> 192 {topLanguages.map((lang) => ( 193 <span key={lang.name} style={base.languageTag}> 194 {lang.name} 195 </span> 196 ))} 197 </div> 198 ) : null; 199 })()} 200 201 {/* Right side: Stars and View on Tangled link */} 202 <div style={base.rightSection}> 203 {/* Stars */} 204 {showStarCount && ( 205 <div 206 style={{ 207 ...base.starCountContainer, 208 color: `var(--atproto-color-text-secondary)`, 209 }} 210 > 211 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ display: "block" }}> 212 <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"/> 213 </svg> 214 {starsLoading ? ( 215 <span style={base.starCount}>...</span> 216 ) : starsError ? ( 217 <span style={base.starCount}></span> 218 ) : ( 219 <span style={base.starCount}>{starCount}</span> 220 )} 221 </div> 222 )} 223 224 {/* View on Tangled link */} 225 <a 226 href={viewUrl} 227 target="_blank" 228 rel="noopener noreferrer" 229 style={{ 230 ...base.viewLink, 231 color: `var(--atproto-color-link)`, 232 }} 233 > 234 View on Tangled 235 </a> 236 </div> 237 </div> 238 </div> 239 ); 240}; 241 242const base: Record<string, React.CSSProperties> = { 243 container: { 244 fontFamily: "system-ui, sans-serif", 245 borderRadius: 6, 246 overflow: "hidden", 247 transition: 248 "background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease", 249 width: "100%", 250 }, 251 header: { 252 padding: "16px", 253 display: "flex", 254 flexDirection: "column", 255 }, 256 headerTop: { 257 display: "flex", 258 justifyContent: "space-between", 259 alignItems: "flex-start", 260 gap: 12, 261 }, 262 headerRight: { 263 display: "flex", 264 alignItems: "center", 265 gap: 8, 266 }, 267 repoName: { 268 fontFamily: 269 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace', 270 fontSize: 18, 271 fontWeight: 600, 272 wordBreak: "break-word", 273 margin: 0, 274 }, 275 iconLink: { 276 display: "flex", 277 alignItems: "center", 278 textDecoration: "none", 279 opacity: 0.7, 280 transition: "opacity 150ms ease", 281 }, 282 description: { 283 padding: "0 16px 16px 16px", 284 fontSize: 14, 285 lineHeight: 1.5, 286 }, 287 languageSection: { 288 padding: "0 16px 16px 16px", 289 display: "flex", 290 justifyContent: "space-between", 291 alignItems: "center", 292 gap: 12, 293 flexWrap: "wrap", 294 }, 295 languageTags: { 296 display: "flex", 297 gap: 8, 298 flexWrap: "wrap", 299 }, 300 languageTag: { 301 fontSize: 12, 302 fontWeight: 500, 303 padding: "4px 10px", 304 background: `var(--atproto-color-bg)`, 305 borderRadius: 12, 306 border: "1px solid var(--atproto-color-border)", 307 }, 308 rightSection: { 309 display: "flex", 310 alignItems: "center", 311 gap: 12, 312 }, 313 starCountContainer: { 314 display: "flex", 315 alignItems: "center", 316 gap: 4, 317 fontSize: 13, 318 }, 319 starCount: { 320 fontSize: 13, 321 fontWeight: 500, 322 }, 323 viewLink: { 324 fontSize: 13, 325 fontWeight: 500, 326 textDecoration: "none", 327 }, 328}; 329 330export default TangledRepoRenderer;