Skip to content

g1t/apps/web/app/components/repo-about.tsx

442 lines15,804 bytesCodeBlame
1/**
2 * The About beside a repository's files: what it is (description, home
3 * page, topics), what its files say (readme, license, security policy),
4 * how people follow it (activity, stars, watching), then its releases,
5 * deployments, packages, contributors and languages, each a section.
6 *
7 * What is read from the default branch (license, languages, contributors)
8 * is worked out in the background and kept by commit; it streams in after
9 * the files, and while it is first being worked out the panel asks for it
10 * again by itself (`about.json`).
11 */
12import {
13 Activity,
14 BookOpen,
15 Eye,
16 Link2,
17 Package as PackageIcon,
18 Plus,
19 Scale,
20 ShieldCheck,
21 Star,
22 Tag as TagIcon,
23} from "lucide-react";
24import { type ReactNode, Suspense, useEffect, useState } from "react";
25import { Await, Link } from "react-router";
26
27import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts";
28
29import { compact, contributorHref, count, languageBar, licenseLabel } from "../lib/about";
30import { PackageIcon as EcosystemIcon } from "./package-icon";
31import { Topics } from "./topics";
32import { Avatar, Pill, TimeAgo } from "./ui";
33import { Hint } from "./ui/hint";
34import { SkeletonLine } from "./ui/skeleton";
35
36/** How many contributors' avatars the About shows. */
37const AVATARS = 14;
38/** How often, and how many times, it asks again while the default branch is first read. */
39const RETRY_MS = 3_000;
40const RETRIES = 6;
41
42function encodePath(path: string): string {
43 return path.split("/").map(encodeURIComponent).join("/");
44}
45
46function Section({ title, count: n, to, children }: { title: string; count?: number; to?: string; children: ReactNode }) {
47 const heading = (
48 <>
49 {title}
50 {n != null && n > 0 && <span className="rounded-full bg-raised px-1.5 text-xs font-medium text-muted tabular-nums">{n.toLocaleString("en-US")}</span>}
51 </>
52 );
53 return (
54 <section className="border-t border-line pt-4">
55 <h2 className="mb-2.5 text-sm font-semibold">
56 {to ? (
57 <Link to={to} className="inline-flex items-center gap-1.5 hover:text-accent">
58 {heading}
59 </Link>
60 ) : (
61 <span className="inline-flex items-center gap-1.5">{heading}</span>
62 )}
63 </h2>
64 {children}
65 </section>
66 );
67}
68
69function Row({ icon, children }: { icon: ReactNode; children: ReactNode }) {
70 return (
71 <li className="flex min-w-0 items-center gap-2">
72 <span className="flex size-4 shrink-0 items-center justify-center text-faint">{icon}</span>
73 <span className="min-w-0 truncate">{children}</span>
74 </li>
75 );
76}
77
78const ROW_LINK = "hover:text-accent";
79
80/** The colored bar and each language's share. */
81export function LanguageBar({ languages }: { languages: RepoAbout["languages"] }) {
82 const bar = languageBar(languages);
83 return (
84 <div>
85 <div className="flex h-2 overflow-hidden rounded-full bg-raised" role="img" aria-label={bar.map((l) => `${l.name} ${l.percent}%`).join(", ")}>
86 {bar.map((language) => (
87 <span
88 key={language.name}
89 className="h-full border-r border-bg last:border-r-0"
90 style={{ width: `${Math.max(language.percent, 0.6)}%`, background: language.color ?? "var(--color-faint)" }}
91 />
92 ))}
93 </div>
94 <ul className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
95 {bar.map((language) => (
96 <li key={language.name} className="inline-flex items-center gap-1.5">
97 <span className="size-2 shrink-0 rounded-full" style={{ background: language.color ?? "var(--color-faint)" }} />
98 <span className="font-medium text-fg">{language.name}</span>
99 <span className="text-faint tabular-nums">{language.percent.toFixed(1)}%</span>
100 </li>
101 ))}
102 </ul>
103 </div>
104 );
105}
106
107/** The About of a repository that is read from its default branch: it asks again while that is pending. */
108function useLive(base: string, first: RepoAbout): RepoAbout {
109 const [about, setAbout] = useState(first);
110 useEffect(() => setAbout(first), [first]);
111 useEffect(() => {
112 if (!about.pending) return;
113 let tries = 0;
114 let stopped = false;
115 const timer = setInterval(async () => {
116 tries += 1;
117 if (tries > RETRIES) return clearInterval(timer);
118 try {
119 const response = await fetch(`${base}/about.json`, { headers: { accept: "application/json" } });
120 if (!response.ok || stopped) return;
121 const next = (await response.json()) as RepoAbout;
122 if (!next.pending) {
123 clearInterval(timer);
124 setAbout(next);
125 }
126 } catch {
127 // Asked again on the next tick.
128 }
129 }, RETRY_MS);
130 return () => {
131 stopped = true;
132 clearInterval(timer);
133 };
134 }, [base, about.pending]);
135 return about;
136}
137
138/** Reading the default branch for the first time. */
139function Reading() {
140 return <p className="text-xs text-faint">Reading the default branch…</p>;
141}
142
143function Releases({ base, about, canPush }: { base: string; about: RepoAbout; canPush: boolean }) {
144 const latest = about.latestRelease;
145 return (
146 <Section title="Releases" count={about.releases} to={`${base}/releases`}>
147 {latest ? (
148 <div className="text-sm">
149 <Link to={`${base}/releases/tag/${encodePath(latest.tagName)}`} className="flex items-start gap-2 hover:text-accent">
150 <TagIcon size={15} className="mt-0.5 shrink-0 text-success" />
151 <span className="min-w-0">
152 <span className="flex flex-wrap items-center gap-1.5">
153 <span className="truncate font-medium">{latest.name || latest.tagName}</span>
154 <Pill>Latest</Pill>
155 </span>
156 <span className="text-xs text-faint">{latest.publishedAt && <TimeAgo at={latest.publishedAt} />}</span>
157 </span>
158 </Link>
159 {about.releases > 1 && (
160 <Link to={`${base}/releases`} className="mt-2 block text-xs text-muted hover:text-accent">
161 + {count(about.releases - 1, "release")}
162 </Link>
163 )}
164 </div>
165 ) : about.releases > 0 ? (
166 <Link to={`${base}/releases`} className="text-sm text-muted hover:text-accent">
167 {count(about.releases, "release")}
168 </Link>
169 ) : (
170 <div className="text-sm text-muted">
171 <p>No releases published</p>
172 {canPush && (
173 <Link to={`${base}/releases/new`} className="mt-1 inline-flex items-center gap-1 text-accent hover:underline">
174 <Plus size={13} />
175 Create a new release
176 </Link>
177 )}
178 </div>
179 )}
180 </Section>
181 );
182}
183
184function Packages({ packages }: { packages: PackageSummary[] | null }) {
185 return (
186 <Section title="Packages" count={packages?.length}>
187 {packages && packages.length > 0 ? (
188 <ul className="space-y-2 text-sm">
189 {packages.slice(0, 5).map((pkg) => (
190 <li key={pkg.id}>
191 <Link to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`} className="flex min-w-0 items-center gap-2 hover:text-accent">
192 <EcosystemIcon ecosystem={pkg.ecosystem} size={16} />
193 <span className="truncate">{pkg.name}</span>
194 {pkg.latest && <span className="ml-auto shrink-0 font-mono text-xs text-faint">{pkg.latest}</span>}
195 </Link>
196 </li>
197 ))}
198 </ul>
199 ) : (
200 <div className="text-sm text-muted">
201 <p>No packages published</p>
202 <a href="https://docs.g1t.sh/guides/packages/" className="mt-1 inline-flex items-center gap-1 text-accent hover:underline">
203 <PackageIcon size={13} />
204 Publish your first package
205 </a>
206 </div>
207 )}
208 </Section>
209 );
210}
211
212function Contributors({ base, about }: { base: string; about: RepoAbout }) {
213 const shown = about.topContributors.slice(0, AVATARS);
214 return (
215 <Section title="Contributors" count={about.contributors} to={`${base}/contributors`}>
216 {about.pending ? (
217 <Reading />
218 ) : shown.length === 0 ? (
219 <p className="text-sm text-muted">No commits yet</p>
220 ) : (
221 <>
222 <ul className="flex flex-wrap gap-1.5">
223 {shown.map((contributor) => {
224 const href = contributorHref(contributor);
225 const label = `${contributor.name} · ${count(contributor.commits, "commit")}`;
226 const face = <Avatar name={contributor.name} image={contributor.avatar} size={30} system={contributor.kind === "g1t"} />;
227 return (
228 <li key={`${contributor.kind}:${contributor.name}`}>
229 <Hint label={label}>
230 {href ? (
231 <Link to={href} aria-label={label} className="block rounded-full">
232 {face}
233 </Link>
234 ) : (
235 <span aria-label={label} className="block rounded-full">
236 {face}
237 </span>
238 )}
239 </Hint>
240 </li>
241 );
242 })}
243 </ul>
244 {about.contributors > shown.length && (
245 <Link to={`${base}/contributors`} className="mt-2 block text-xs text-muted hover:text-accent">
246 + {count(about.contributors - shown.length, "contributor")}
247 </Link>
248 )}
249 </>
250 )}
251 </Section>
252 );
253}
254
255/** What the default branch says and the counts, once they have streamed in. */
256function Facts({ base, gitRef, about, watchers }: { base: string; gitRef: string; about: RepoAbout | null; watchers: number | null }) {
257 const activity = (
258 <Row icon={<Activity size={15} />}>
259 <Link to={`${base}/activity`} className={ROW_LINK}>
260 Activity
261 </Link>
262 </Row>
263 );
264 if (!about) return activity;
265 const blob = (path: string) => `${base}/blob/${encodePath(gitRef)}/${encodePath(path)}`;
266 return (
267 <>
268 {about.license && (
269 <Row icon={<Scale size={15} />}>
270 <Link to={blob(about.license.path)} className={ROW_LINK}>
271 {licenseLabel(about.license)}
272 </Link>
273 </Row>
274 )}
275 {about.securityPolicy && (
276 <Row icon={<ShieldCheck size={15} />}>
277 <Link to={blob(about.securityPolicy)} className={ROW_LINK}>
278 Security policy
279 </Link>
280 </Row>
281 )}
282 {activity}
283 <Row icon={<Star size={15} />}>
284 <Link to={`${base}/stargazers`} className={ROW_LINK}>
285 <span className="font-medium text-fg">{compact(about.stars)}</span> {about.stars === 1 ? "star" : "stars"}
286 </Link>
287 </Row>
288 {watchers != null && (
289 <Row icon={<Eye size={15} />}>
290 <span>
291 <span className="font-medium text-fg">{watchers.toLocaleString("en-US")}</span> watching
292 </span>
293 </Row>
294 )}
295 </>
296 );
297}
298
299function FactsSkeleton() {
300 return (
301 <>
302 {[0, 1, 2].map((key) => (
303 <li key={key} aria-hidden="true" className="flex items-center gap-2">
304 <span className="size-4 shrink-0" />
305 <SkeletonLine barClassName="w-24" />
306 </li>
307 ))}
308 </>
309 );
310}
311
312function LoadedSections({
313 base,
314 first,
315 canPush,
316 packages,
317 deployments,
318}: {
319 base: string;
320 first: RepoAbout;
321 canPush: boolean;
322 packages: Promise<PackageSummary[] | null> | PackageSummary[] | null;
323 deployments: ReactNode;
324}) {
325 const about = useLive(base, first);
326 return (
327 <>
328 <Releases base={base} about={about} canPush={canPush} />
329 <Suspense fallback={<SectionSkeleton title="Packages" />}>
330 <Await resolve={packages} errorElement={<Packages packages={null} />}>
331 {(found) => <Packages packages={found} />}
332 </Await>
333 </Suspense>
334 {deployments}
335 <Contributors base={base} about={about} />
336 <Section title="Languages">
337 {about.pending ? <Reading /> : about.languages.length > 0 ? <LanguageBar languages={about.languages} /> : <p className="text-sm text-muted">No code to count</p>}
338 </Section>
339 </>
340 );
341}
342
343function SectionSkeleton({ title }: { title: string }) {
344 return (
345 <section className="border-t border-line pt-4" aria-busy="true">
346 <h2 className="mb-2.5 text-sm font-semibold">{title}</h2>
347 <SkeletonLine barClassName="w-32" />
348 </section>
349 );
350}
351
352export type AboutData = {
353 /** What the default branch says, stars and releases; null when it could not be read. */
354 about: Promise<RepoAbout | null> | RepoAbout | null;
355 /** How many watch it; null when it could not be read. */
356 watchers: Promise<number | null> | number | null;
357 /** Packages published from it; null when they could not be read. */
358 packages: Promise<PackageSummary[] | null> | PackageSummary[] | null;
359};
360
361export function RepoAboutPanel({
362 repo,
363 gitRef,
364 readme,
365 data,
366 canPush,
367 homepage,
368 deployments = null,
369}: {
370 repo: Repo;
371 gitRef: string;
372 readme: boolean;
373 data: AboutData;
374 canPush: boolean;
375 /** The project's homepage (lib/about.ts `projectHomepage`); the repository's website when null. */
376 homepage?: string | null;
377 /** The Deployments section (deployments-panel.tsx): each environment's latest. */
378 deployments?: ReactNode;
379}) {
380 const base = `/${repo.namespace}/${repo.name}`;
381 const site = homepage ?? repo.website ?? null;
382 const facts = (about: RepoAbout | null, watchers: number | null) => <Facts base={base} gitRef={gitRef} about={about} watchers={watchers} />;
383 return (
384 <aside className="space-y-4">
385 <div>
386 <h2 className="text-base font-semibold">About</h2>
387 <p className="mt-2.5 text-sm text-fg-soft">{repo.description ?? (site || repo.topics.length > 0 ? "No description." : "No description, website, or topics provided.")}</p>
388 {site && (
389 <a href={site} rel="noopener nofollow" className="mt-2.5 flex min-w-0 items-center gap-2 text-sm font-medium text-accent hover:underline">
390 <Link2 size={15} className="shrink-0" />
391 <span className="truncate">{site.replace(/^https?:\/\//, "").replace(/\/$/, "")}</span>
392 </a>
393 )}
394 <Topics topics={repo.topics} className="mt-3" />
395 <ul className="mt-4 space-y-2.5 text-sm text-muted">
396 {readme && (
397 <Row icon={<BookOpen size={15} />}>
398 <a href="#readme" className={ROW_LINK}>
399 Readme
400 </a>
401 </Row>
402 )}
403 <Suspense fallback={<FactsSkeleton />}>
404 <Await resolve={data.about} errorElement={facts(null, null)}>
405 {(about) => (
406 <Suspense fallback={facts(about, null)}>
407 <Await resolve={data.watchers} errorElement={facts(about, null)}>
408 {(watchers) => facts(about, watchers)}
409 </Await>
410 </Suspense>
411 )}
412 </Await>
413 </Suspense>
414 </ul>
415 <p className="mt-4 text-xs text-faint">
416 Created <TimeAgo at={repo.createdAt} />
417 </p>
418 </div>
419 <Suspense
420 fallback={
421 <>
422 <SectionSkeleton title="Releases" />
423 <SectionSkeleton title="Packages" />
424 {deployments}
425 <SectionSkeleton title="Contributors" />
426 <SectionSkeleton title="Languages" />
427 </>
428 }
429 >
430 <Await resolve={data.about} errorElement={<>{deployments}</>}>
431 {(about) =>
432 about ? (
433 <LoadedSections base={base} first={about} canPush={canPush} packages={data.packages} deployments={deployments} />
434 ) : (
435 <>{deployments}</>
436 )
437 }
438 </Await>
439 </Suspense>
440 </aside>
441 );
442}