Files
multica/apps/web/features/landing/utils/use-github-stars.ts
Bohan Jiang 09f90abb70 MUL-3568 feat(landing): show live GitHub star count on the header GitHub button (#4451)
* feat(landing): show live GitHub star count on the header GitHub button

Add a small client hook (useGithubStars) that fetches stargazers_count
from the GitHub API and a formatStarCount helper that renders it in
GitHub's compact repo-header style (e.g. "37.6k"). The landing header's
GitHub button now appends a star badge (faint divider + filled star +
count) on both the desktop and mobile menu entries.

Fetched client-side on purpose: LandingHeader is shared across every
marketing page, so one client fetch covers them all without threading a
server value through each render site, and each visitor calls the API
from their own IP, sidestepping the shared-outbound-IP rate limit the
server-side github-release fetcher works around with a PAT. The result
is memoized at module scope (plus in-flight dedupe); a failed fetch
caches null and the button degrades to the plain "GitHub" label.

* fix(landing): drop the star glyph from the GitHub star badge

In the GitHub button context the number already reads as the star count,
so the icon is redundant. Keep the divider + count only.
2026-06-23 15:51:14 +08:00

86 lines
2.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
/**
* Live GitHub star count for the landing header's "GitHub" button.
*
* Fetched client-side on purpose: the badge lives in the shared
* {@link LandingHeader}, which renders on every marketing page, so a single
* client fetch covers them all without threading a server value through eight
* render sites. Each visitor calls the GitHub API from their own IP, which
* sidesteps the shared-outbound-IP rate limit that the server-side
* `github-release.ts` fetcher has to work around with a PAT.
*
* The result is memoized at module scope (plus an in-flight promise) so
* client-side navigation between landing pages reuses the first fetch instead
* of hitting the API again. A failed fetch caches `null` so we don't retry in
* a loop; the button just degrades to its plain "GitHub" label.
*/
const REPO = "multica-ai/multica";
// `undefined` = never fetched; `number` = resolved count; `null` = fetch failed.
let cachedStars: number | null | undefined;
let inFlight: Promise<number | null> | null = null;
async function loadStars(): Promise<number | null> {
if (cachedStars !== undefined) return cachedStars;
if (inFlight) return inFlight;
inFlight = fetch(`https://api.github.com/repos/${REPO}`, {
headers: { Accept: "application/vnd.github+json" },
})
.then((res) => {
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
return res.json() as Promise<{ stargazers_count?: unknown }>;
})
.then((data) => {
const count =
typeof data.stargazers_count === "number" ? data.stargazers_count : null;
cachedStars = count;
return count;
})
.catch(() => {
cachedStars = null;
return null;
})
.finally(() => {
inFlight = null;
});
return inFlight;
}
export function useGithubStars(): number | null {
const [stars, setStars] = useState<number | null>(cachedStars ?? null);
useEffect(() => {
let active = true;
void loadStars().then((count) => {
if (active && count != null) setStars(count);
});
return () => {
active = false;
};
}, []);
return stars;
}
/**
* Compact star count matching GitHub's own repo-header style: one decimal
* thousands/millions with the trailing ".0" trimmed ("1k", "37.6k", "1.2m").
* Counts below 1,000 render exactly. Mirrors GitHub's `toFixed(1)` rounding so
* our badge reads the same as the figure on the repo page.
*/
export function formatStarCount(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}