"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
interface GitHubStarsProps extends React.HTMLAttributes<HTMLDivElement> {
owner: string;
repo: string;
days?: number;
staticStars?: number;
staticData?: number[];
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
}
function generateLinePath(
data: number[],
width: number,
height: number,
offsetY: number = 0,
): string {
if (data.length === 0) return "";
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const normalized = data.map((v) => (v - min) / range);
const points = normalized.map((value, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - value * height + offsetY;
return { x, y };
});
return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
}
function generateAreaPath(
data: number[],
width: number,
height: number,
): string {
if (data.length === 0) return "";
const linePath = generateLinePath(data, width, height);
return `${linePath} L${width},${height} L0,${height} Z`;
}
function generateHatchLines(
width: number,
height: number,
spacing: number,
): string {
const lines: string[] = [];
const diagonal = Math.sqrt(width * width + height * height);
const count = Math.ceil(diagonal / spacing) * 2;
for (let i = -count; i < count; i++) {
const offset = i * spacing;
lines.push(
`M${offset - height},${height + 10} L${offset + width + 10},-10`,
);
}
return lines.join(" ");
}
interface AreaChartProps {
data: number[];
className?: string;
}
const AREA_CHART_VIEWBOX_WIDTH = 300;
const AREA_CHART_VIEWBOX_HEIGHT = 200;
function AreaChart({ data, className }: AreaChartProps) {
const id = useId();
const gradientId = `gradient-${id}`;
const maskId = `mask-${id}`;
const width = AREA_CHART_VIEWBOX_WIDTH;
const height = AREA_CHART_VIEWBOX_HEIGHT;
const mainLinePath = useMemo(
() => generateLinePath(data, width, height),
[data, height, width],
);
const areaPath = useMemo(
() => generateAreaPath(data, width, height),
[data, height, width],
);
const hatchPath = useMemo(
() => generateHatchLines(width, height, 10),
[height, width],
);
return (
<svg
viewBox={`0 0 ${width} ${height}`}
fill="none"
preserveAspectRatio="xMidYMid meet"
className={cn("w-full h-auto text-muted-foreground", className)}
aria-hidden="true"
>
<defs>
<linearGradient
id={gradientId}
x1={width / 2}
y1={0}
x2={width / 2}
y2={height}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="currentColor" stopOpacity={0.8} />
<stop offset="0.5" stopColor="currentColor" stopOpacity={0.25} />
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
<mask
id={maskId}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width={width}
height={height}
>
<path d={areaPath} fill={`url(#${gradientId})`} />
</mask>
</defs>
<g clipPath="inset(0)">
<g mask={`url(#${maskId})`} opacity={0.7}>
<path d={hatchPath} stroke="currentColor" strokeWidth={1} />
</g>
<path
d={mainLinePath}
stroke="currentColor"
strokeWidth={1.5}
strokeOpacity={0.6}
fill="none"
/>
</g>
</svg>
);
}
export function GitHubStars({
owner,
repo,
days = 30,
staticStars,
staticData,
className,
...props
}: GitHubStarsProps) {
const [data, setData] = useState<number[]>(staticData || []);
const [totalStars, setTotalStars] = useState<number | null>(
staticStars ?? null,
);
const [loading, setLoading] = useState(!staticData);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (staticData && staticStars !== undefined) return;
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
);
if (!response.ok) {
throw new Error("Failed to fetch repository data");
}
const repoData = await response.json();
setTotalStars(repoData.stargazers_count);
const historyResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/stargazers?per_page=100`,
{
headers: {
Accept: "application/vnd.github.star+json",
},
},
);
if (historyResponse.ok) {
const stargazers = await historyResponse.json();
const now = new Date();
const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const dailyCounts = new Map<string, number>();
for (const star of stargazers) {
const date = new Date(star.starred_at);
if (date >= cutoff) {
const dateKey = date.toISOString().split("T")[0];
dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1);
}
}
const result: number[] = [];
let cumulative = 0;
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const dateKey = date.toISOString().split("T")[0];
cumulative += dailyCounts.get(dateKey) || 0;
result.push(cumulative);
}
const hasData = result.some((v) => v > 0);
if (hasData) {
setData(result);
} else {
const total = repoData.stargazers_count || 100;
const baseGrowth = total * 0.002;
let cumulative = 0;
const generated = Array(days)
.fill(0)
.map((_, i) => {
const trend = baseGrowth * (1 + Math.sin(i * 0.3) * 0.3);
const noise = (Math.random() - 0.3) * baseGrowth * 0.5;
cumulative += Math.max(0, trend + noise);
return Math.floor(cumulative);
});
setData(generated);
}
} else {
const total = repoData.stargazers_count || 100;
const baseGrowth = total * 0.002;
let cumulative = 0;
const generated = Array(days)
.fill(0)
.map((_, i) => {
const trend = baseGrowth * (1 + Math.sin(i * 0.3) * 0.3);
const noise = (Math.random() - 0.3) * baseGrowth * 0.5;
cumulative += Math.max(0, trend + noise);
return Math.floor(cumulative);
});
setData(generated);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
fetchData();
}, [owner, repo, days, staticData, staticStars]);
if (loading) {
return (
<div
data-slot="github-stars"
className={cn(
"flex flex-col gap-4 p-6 border border-border rounded-lg bg-card",
className,
)}
{...props}
>
<div className="flex flex-col gap-1">
<div className="h-10 w-20 bg-muted rounded animate-pulse" />
<div className="h-4 w-48 bg-muted rounded animate-pulse" />
</div>
<svg
viewBox={`0 0 ${AREA_CHART_VIEWBOX_WIDTH} ${AREA_CHART_VIEWBOX_HEIGHT}`}
preserveAspectRatio="xMidYMid meet"
className="w-full h-auto text-muted"
aria-hidden="true"
>
<rect
width={AREA_CHART_VIEWBOX_WIDTH}
height={AREA_CHART_VIEWBOX_HEIGHT}
rx={4}
fill="currentColor"
className="animate-pulse"
/>
</svg>
</div>
);
}
if (error) {
return (
<div
data-slot="github-stars"
className={cn(
"flex flex-col gap-4 p-6 border border-destructive/50 rounded-lg bg-destructive/10",
className,
)}
{...props}
>
<p className="text-sm text-destructive">{error}</p>
</div>
);
}
return (
<div
data-slot="github-stars"
className={cn(
"flex flex-col gap-4 p-6 border border-border rounded-lg bg-card",
className,
)}
{...props}
>
<div className="flex flex-col gap-1">
<span className="text-4xl font-bold tracking-tight">
{totalStars !== null ? formatNumber(totalStars) : "—"}
</span>
<span className="text-sm text-muted-foreground">
GitHub Stars for {owner}/{repo}
</span>
<span className="text-xs text-muted-foreground/60">
Last {days} days
</span>
</div>
<AreaChart
data={data}
className="text-neutral-500 dark:text-neutral-400"
/>
</div>
);
}