Skip to content

Commit 0eba923

Browse files
feat(website): GitHub stars/forks/PRs counter in navbar and community (#48)
GitHubCounter pill with server-fetched, hourly-revalidated counts. Desktop navbar cycles stars, forks and PRs every 3s (pauses on hover/focus, static under reduced motion); Community section shows all three. Mobile navbar trimmed to logo + menu. Closes #36.
1 parent 7cdf183 commit 0eba923

7 files changed

Lines changed: 702 additions & 52 deletions

File tree

‎src/app/globals.css‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@
4343
--color-emerald-950: #00120c;
4444
}
4545

46+
/* ── Landing: GitHub counter metric swap ── */
47+
@theme {
48+
--animate-counter-in: counter-in 300ms ease-out;
49+
50+
@keyframes counter-in {
51+
from {
52+
opacity: 0;
53+
transform: translateY(0.25rem);
54+
}
55+
to {
56+
opacity: 1;
57+
transform: translateY(0);
58+
}
59+
}
60+
}
61+
4662
/* ── Fumadocs theming ── */
4763
:root {
4864
color-scheme: light;

‎src/app/page.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,23 @@ import { Community } from "@/components/landing/community";
66
import { Faq } from "@/components/landing/faq";
77
import { Footer } from "@/components/landing/footer";
88

9+
import { getGitHubStats } from "@/lib/github";
10+
911
export const metadata: Metadata = {
1012
alternates: { canonical: "/" },
1113
};
1214

13-
export default function Home() {
15+
export default async function Home() {
16+
const stats = await getGitHubStats();
17+
1418
return (
1519
<div className="flex flex-1 flex-col">
16-
<Navbar />
20+
<Navbar stats={stats} />
1721
<main>
1822
<Hero />
1923
<Features />
2024

21-
<Community />
25+
<Community stats={stats} />
2226
<Faq />
2327
</main>
2428
<Footer />

‎src/components/landing/community.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Reveal } from "./reveal";
2+
import { GitHubCounter } from "./github-counter";
3+
import type { GitHubStats } from "@/lib/github";
24

35
const communityLinks = [
46
{
@@ -51,9 +53,13 @@ const communityLinks = [
5153
},
5254
];
5355

54-
export function Community() {
56+
export interface CommunityProps {
57+
stats?: GitHubStats;
58+
}
59+
60+
export function Community({ stats }: CommunityProps = {}) {
5561
return (
56-
<section className="relative overflow-hidden py-24 md:py-32">
62+
<section id="community" className="relative overflow-hidden py-24 md:py-32">
5763
<div className="relative mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
5864
<div className="text-center">
5965
<Reveal>
@@ -66,6 +72,13 @@ export function Community() {
6672
Connect with other developers, get help, and contribute to the project
6773
</p>
6874
</Reveal>
75+
<Reveal delay={0.15}>
76+
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
77+
<GitHubCounter metric="stars" count={stats?.stars} />
78+
<GitHubCounter metric="forks" count={stats?.forks} />
79+
<GitHubCounter metric="prs" count={stats?.prs} />
80+
</div>
81+
</Reveal>
6982
</div>
7083

7184
<div className="mt-16 grid gap-6 sm:grid-cols-3">
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import * as React from "react";
2+
import { Star, GitFork, GitPullRequest } from "lucide-react";
3+
import { cn } from "@/lib/utils";
4+
import type { GitHubStats } from "@/lib/github";
5+
6+
export type GitHubMetric = "stars" | "forks" | "prs";
7+
8+
export interface GitHubCounterProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
9+
metric?: GitHubMetric;
10+
count?: number | null;
11+
hideCountOnMobile?: boolean;
12+
}
13+
14+
/**
15+
* Format a raw GitHub count into a friendly bucket followed by `+`.
16+
* - Numbers below 10 (or missing/0) return `null` so no count is shown.
17+
* - 10-99 round down to the nearest 10 (e.g. 10+, 40+).
18+
* - 100-999 round down to the nearest 50 (e.g. 150+, 500+, 950+).
19+
* - 1000+ use Intl.NumberFormat compact notation rounded down to 1 decimal place (e.g. 1k+, 1.2k+, 12.5k+).
20+
*/
21+
export function formatGitHubCount(count?: number | null): string | null {
22+
if (count == null || count < 10) {
23+
return null;
24+
}
25+
if (count < 100) {
26+
const bucket = Math.floor(count / 10) * 10;
27+
return `${bucket}+`;
28+
}
29+
if (count < 1000) {
30+
const bucket = Math.floor(count / 50) * 50;
31+
return `${bucket}+`;
32+
}
33+
const rounded = Math.floor(count / 100) * 100;
34+
const formatted = new Intl.NumberFormat("en", {
35+
notation: "compact",
36+
maximumFractionDigits: 1,
37+
})
38+
.format(rounded)
39+
.toLowerCase();
40+
return `${formatted}+`;
41+
}
42+
43+
const metricConfig = {
44+
stars: {
45+
label: "Star babelize-elements on GitHub",
46+
noun: "stars",
47+
href: "https://github.com/babelize/babelize-elements",
48+
icon: Star,
49+
},
50+
forks: {
51+
label: "Fork babelize-elements on GitHub",
52+
noun: "forks",
53+
href: "https://github.com/babelize/babelize-elements/forks",
54+
icon: GitFork,
55+
},
56+
prs: {
57+
label: "Pull requests for babelize-elements on GitHub",
58+
noun: "PRs",
59+
href: "https://github.com/babelize/babelize-elements/pulls",
60+
icon: GitPullRequest,
61+
},
62+
} as const;
63+
64+
export function GitHubCounter({
65+
metric = "stars",
66+
count,
67+
hideCountOnMobile = false,
68+
className,
69+
...props
70+
}: GitHubCounterProps) {
71+
const config = metricConfig[metric] ?? metricConfig.stars;
72+
const formattedCount = formatGitHubCount(count);
73+
const MetricIcon = config.icon;
74+
75+
const ariaLabel = formattedCount
76+
? `${config.label} (${formattedCount} ${config.noun})`
77+
: config.label;
78+
79+
return (
80+
<a
81+
href={props.href ?? config.href}
82+
target="_blank"
83+
rel="noopener noreferrer"
84+
aria-label={ariaLabel}
85+
className={cn(
86+
"group inline-flex h-9 items-center rounded-full border border-white/[0.1] bg-white/[0.03] text-sm text-white/80 transition-colors hover:bg-white/5 hover:text-white",
87+
className,
88+
)}
89+
{...props}
90+
>
91+
{/* Left: GitHub logo */}
92+
<span className="flex items-center justify-center pl-3 pr-2.5">
93+
<svg
94+
width="16"
95+
height="16"
96+
viewBox="0 0 24 24"
97+
fill="currentColor"
98+
aria-hidden="true"
99+
className="size-4 text-white/80 transition-colors group-hover:text-white"
100+
>
101+
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
102+
</svg>
103+
</span>
104+
105+
{/* 1px vertical divider */}
106+
<span className="h-3.5 w-px bg-white/[0.1]" aria-hidden="true" />
107+
108+
{/* Right: Count and metric icon */}
109+
<span className="flex items-center gap-1.5 pl-2.5 pr-3">
110+
{formattedCount && (
111+
<span
112+
className={cn(
113+
"text-xs font-medium sm:text-sm",
114+
hideCountOnMobile && "hidden sm:inline",
115+
)}
116+
>
117+
{formattedCount}
118+
</span>
119+
)}
120+
<MetricIcon
121+
size={14}
122+
className="text-white/70 transition-colors group-hover:text-white"
123+
aria-hidden="true"
124+
/>
125+
</span>
126+
</a>
127+
);
128+
}
129+
130+
export interface RotatingGitHubCounterProps extends Omit<
131+
React.AnchorHTMLAttributes<HTMLAnchorElement>,
132+
"children"
133+
> {
134+
stats?: GitHubStats;
135+
intervalMs?: number;
136+
className?: string;
137+
}
138+
139+
/**
140+
* Navbar GitHub counter that smoothly cycles between available traction metrics:
141+
* stars → forks → PRs → stars (every 3 seconds).
142+
*
143+
* Features:
144+
* - Client component; data fetched on server and passed via props.
145+
* - Short fade/slide transition between metrics.
146+
* - Link (href) and aria-label follow the currently visible metric.
147+
* - Pauses rotation on hover and focus.
148+
* - Respects prefers-reduced-motion: no rotation, stays on stars.
149+
* - No aria-live (avoids screen reader announcement spam every 3s).
150+
* - Fixed width across metrics to prevent navbar layout shifts.
151+
* - Skips metrics with no count (< 10 or null). If none have a count, renders static stars pill.
152+
*/
153+
function subscribeReducedMotion(callback: () => void) {
154+
if (typeof window === "undefined" || !window.matchMedia) return () => {};
155+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
156+
if (mediaQuery.addEventListener) {
157+
mediaQuery.addEventListener("change", callback);
158+
return () => mediaQuery.removeEventListener("change", callback);
159+
} else if (mediaQuery.addListener) {
160+
mediaQuery.addListener(callback);
161+
return () => mediaQuery.removeListener(callback);
162+
}
163+
return () => {};
164+
}
165+
166+
function getReducedMotionSnapshot(): boolean {
167+
if (typeof window === "undefined" || !window.matchMedia) return false;
168+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
169+
}
170+
171+
function getReducedMotionServerSnapshot(): boolean {
172+
return false;
173+
}
174+
175+
export function RotatingGitHubCounter({
176+
stats,
177+
intervalMs = 3000,
178+
className,
179+
...props
180+
}: RotatingGitHubCounterProps) {
181+
const [currentIndex, setCurrentIndex] = React.useState(0);
182+
const [isPaused, setIsPaused] = React.useState(false);
183+
const prefersReducedMotion = React.useSyncExternalStore(
184+
subscribeReducedMotion,
185+
getReducedMotionSnapshot,
186+
getReducedMotionServerSnapshot,
187+
);
188+
189+
// Filter metrics that have a valid formatted count (>= 10)
190+
// Preserves strict cycle order: stars -> forks -> prs
191+
const availableMetrics = React.useMemo(() => {
192+
const list: GitHubMetric[] = [];
193+
if (formatGitHubCount(stats?.stars) !== null) list.push("stars");
194+
if (formatGitHubCount(stats?.forks) !== null) list.push("forks");
195+
if (formatGitHubCount(stats?.prs) !== null) list.push("prs");
196+
return list;
197+
}, [stats?.stars, stats?.forks, stats?.prs]);
198+
199+
const hasValidCounts = availableMetrics.length > 0;
200+
201+
// Rotation timer: cycle every 3s, pause on hover/focus, no rotation on reduced motion
202+
React.useEffect(() => {
203+
if (prefersReducedMotion || isPaused || availableMetrics.length <= 1) {
204+
return;
205+
}
206+
207+
const timer = setInterval(() => {
208+
setCurrentIndex((prev) => (prev + 1) % availableMetrics.length);
209+
}, intervalMs);
210+
211+
return () => clearInterval(timer);
212+
}, [prefersReducedMotion, isPaused, availableMetrics.length, intervalMs]);
213+
214+
// If none have a count, show the static stars pill
215+
if (!hasValidCounts) {
216+
return <GitHubCounter metric="stars" count={null} className={className} {...props} />;
217+
}
218+
219+
// Active metric: force stars if prefers-reduced-motion is true
220+
const activeMetric: GitHubMetric = prefersReducedMotion
221+
? "stars"
222+
: availableMetrics[currentIndex % availableMetrics.length];
223+
224+
const config = metricConfig[activeMetric] ?? metricConfig.stars;
225+
const rawCount = stats?.[activeMetric] ?? null;
226+
const formattedCount = formatGitHubCount(rawCount);
227+
const MetricIcon = config.icon;
228+
229+
const ariaLabel = formattedCount
230+
? `${config.label} (${formattedCount} ${config.noun})`
231+
: config.label;
232+
233+
return (
234+
<a
235+
href={props.href ?? config.href}
236+
target="_blank"
237+
rel="noopener noreferrer"
238+
aria-label={ariaLabel}
239+
onMouseEnter={() => setIsPaused(true)}
240+
onMouseLeave={() => setIsPaused(false)}
241+
onFocus={() => setIsPaused(true)}
242+
onBlur={() => setIsPaused(false)}
243+
className={cn(
244+
"group inline-flex h-9 w-[136px] items-center justify-between rounded-full border border-white/[0.1] bg-white/[0.03] text-sm text-white/80 transition-colors hover:bg-white/5 hover:text-white",
245+
className,
246+
)}
247+
{...props}
248+
>
249+
{/* Left: GitHub logo */}
250+
<span className="flex items-center justify-center pl-3 pr-2.5">
251+
<svg
252+
width="16"
253+
height="16"
254+
viewBox="0 0 24 24"
255+
fill="currentColor"
256+
aria-hidden="true"
257+
className="size-4 text-white/80 transition-colors group-hover:text-white"
258+
>
259+
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
260+
</svg>
261+
</span>
262+
263+
{/* 1px vertical divider */}
264+
<span className="h-3.5 w-px bg-white/[0.1]" aria-hidden="true" />
265+
266+
{/* Right: Count and metric icon with fade/slide transition */}
267+
<span className="flex flex-1 items-center justify-center gap-1.5 pl-2 pr-3 overflow-hidden">
268+
<span
269+
key={activeMetric}
270+
className="inline-flex items-center gap-1.5 motion-safe:animate-counter-in"
271+
>
272+
{formattedCount && (
273+
<span className="text-xs font-medium sm:text-sm">{formattedCount}</span>
274+
)}
275+
<MetricIcon
276+
size={14}
277+
className="text-white/70 transition-colors group-hover:text-white"
278+
aria-hidden="true"
279+
/>
280+
</span>
281+
</span>
282+
</a>
283+
);
284+
}

0 commit comments

Comments
 (0)