mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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.
This commit is contained in:
@@ -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({
|
||||
>
|
||||
<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, 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 (
|
||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||
<span aria-hidden className="h-3 w-px bg-current opacity-25" />
|
||||
<Star className="size-3 fill-current" aria-hidden />
|
||||
{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",
|
||||
|
||||
31
apps/web/features/landing/utils/use-github-stars.test.ts
Normal file
31
apps/web/features/landing/utils/use-github-stars.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
85
apps/web/features/landing/utils/use-github-stars.ts
Normal file
85
apps/web/features/landing/utils/use-github-stars.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user