From 70df4bc07b615af6dfcbf5cc9165611162f19fbb Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 23 Jun 2026 15:14:37 +0800 Subject: [PATCH] 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. --- .../landing/components/landing-header.tsx | 21 ++++- .../landing/utils/use-github-stars.test.ts | 31 +++++++ .../landing/utils/use-github-stars.ts | 85 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 apps/web/features/landing/utils/use-github-stars.test.ts create mode 100644 apps/web/features/landing/utils/use-github-stars.ts diff --git a/apps/web/features/landing/components/landing-header.tsx b/apps/web/features/landing/components/landing-header.tsx index cc0f778d7..c678f5c02 100644 --- a/apps/web/features/landing/components/landing-header.tsx +++ b/apps/web/features/landing/components/landing-header.tsx @@ -2,11 +2,12 @@ import { useState } from "react"; import Link from "next/link"; -import { Menu, X } from "lucide-react"; +import { Menu, Star, X } from "lucide-react"; import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { cn } from "@multica/ui/lib/utils"; import { useAuthStore } from "@multica/core/auth"; import { docsHrefForLocale, useLocale } from "../i18n"; +import { formatStarCount, useGithubStars } from "../utils/use-github-stars"; import { GitHubMark, githubUrl, headerButtonClassName } from "./shared"; export function LandingHeader({ @@ -16,6 +17,8 @@ export function LandingHeader({ }) { const { t, locale } = useLocale(); const user = useAuthStore((s) => s.user); + const stars = useGithubStars(); + const starsLabel = stars != null ? formatStarCount(stars) : null; const [isMenuOpen, setIsMenuOpen] = useState(false); const docsHref = docsHrefForLocale(locale); const navLinks = [ @@ -99,6 +102,7 @@ export function LandingHeader({ > {t.header.github} + {starsLabel ? : null} {t.header.github} + {starsLabel ? : null} @@ -153,6 +158,20 @@ export function LandingHeader({ ); } +/** Star-count segment appended to the header's GitHub button — a faint + * divider, a filled star, and the compact count (e.g. "37.6k"). Inherits the + * button's text color so it adapts to both the dark and light header + * variants. */ +function GitHubStarsBadge({ label }: { label: string }) { + return ( + + + + {label} + + ); +} + function navLinkClassName(variant: "dark" | "light") { return cn( "inline-flex h-9 items-center rounded-[9px] px-3 text-[13px] font-medium transition-colors", diff --git a/apps/web/features/landing/utils/use-github-stars.test.ts b/apps/web/features/landing/utils/use-github-stars.test.ts new file mode 100644 index 000000000..8ceba6e32 --- /dev/null +++ b/apps/web/features/landing/utils/use-github-stars.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { formatStarCount } from "./use-github-stars"; + +describe("formatStarCount", () => { + it("renders counts below 1,000 exactly", () => { + expect(formatStarCount(0)).toBe("0"); + expect(formatStarCount(7)).toBe("7"); + expect(formatStarCount(999)).toBe("999"); + }); + + it("formats thousands with one decimal, GitHub-style", () => { + expect(formatStarCount(37_600)).toBe("37.6k"); + expect(formatStarCount(1_234)).toBe("1.2k"); + expect(formatStarCount(12_300)).toBe("12.3k"); + }); + + it("trims a trailing .0 ('1k', not '1.0k')", () => { + expect(formatStarCount(1_000)).toBe("1k"); + expect(formatStarCount(2_000)).toBe("2k"); + }); + + it("rounds to one decimal like the repo header", () => { + expect(formatStarCount(1_949)).toBe("1.9k"); + expect(formatStarCount(1_990)).toBe("2k"); + }); + + it("formats millions with an 'm' suffix", () => { + expect(formatStarCount(1_200_000)).toBe("1.2m"); + expect(formatStarCount(2_000_000)).toBe("2m"); + }); +}); diff --git a/apps/web/features/landing/utils/use-github-stars.ts b/apps/web/features/landing/utils/use-github-stars.ts new file mode 100644 index 000000000..47f700c9f --- /dev/null +++ b/apps/web/features/landing/utils/use-github-stars.ts @@ -0,0 +1,85 @@ +"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 | null = null; + +async function loadStars(): Promise { + 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(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); +}