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;