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 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;