Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
49a9bfabbc 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:42:11 +08:00
Jiang Bohan
70df4bc07b 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.
2026-06-23 15:14:37 +08:00
3 changed files with 135 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ 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({
>
<GitHubMark className="size-3.5" />
{t.header.github}
{starsLabel ? <GitHubStarsBadge label={starsLabel} /> : null}
</Link>
<Link
href={ctaHref}
@@ -145,6 +149,7 @@ export function LandingHeader({
>
<GitHubMark className="size-3.5" />
{t.header.github}
{starsLabel ? <GitHubStarsBadge label={starsLabel} /> : null}
</Link>
</div>
</div>
@@ -153,6 +158,20 @@ export function LandingHeader({
);
}
/** Star-count segment appended to the header's GitHub button — a faint
* divider and the compact count (e.g. "37.6k"). No star glyph: in the GitHub
* button context the number reads as the star count on its own. Inherits the
* button's text color so it adapts to both the dark and light header
* variants. */
function GitHubStarsBadge({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1.5 tabular-nums">
<span aria-hidden className="h-3 w-px bg-current opacity-25" />
{label}
</span>
);
}
function navLinkClassName(variant: "dark" | "light") {
return cn(
"inline-flex h-9 items-center rounded-[9px] px-3 text-[13px] font-medium transition-colors",

View File

@@ -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");
});
});

View File

@@ -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<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);
}