feat(projects): add interactive GitHub contribution graph

This commit is contained in:
poyrazavsever
2026-08-31 12:26:04 +03:00
parent 1522ef0355
commit a2259848dc
5 changed files with 316 additions and 13 deletions
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useMemo, useState } from "react";
import type { GithubContributionDay } from "@/lib/project-feeds";
const CELL_SIZE = 10;
const CELL_GAP = 2;
const CELL_STEP = CELL_SIZE + CELL_GAP;
const GRID_LEFT = 27;
const GRID_TOP = 20;
const GRAPH_HEIGHT = 104;
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const LEVEL_CLASSES: Record<GithubContributionDay["level"], string> = {
0: "fill-[#eeeeee] dark:fill-zinc-800",
1: "fill-[#ff7373]",
2: "fill-[#ff5959]",
3: "fill-[#dc2626]",
4: "fill-[#b01e1e]",
};
type ContributionGraphLabels = {
calendar: string;
unavailable: string;
none: string;
singular: string;
plural: string;
};
type PositionedDay = GithubContributionDay & {
dayIndex: number;
weekIndex: number;
x: number;
y: number;
};
type ActiveDay = PositionedDay & {
label: string;
};
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
export function GithubContributionGraph({
days,
labels,
locale,
}: {
days: GithubContributionDay[];
labels: ContributionGraphLabels;
locale: string;
}) {
const [activeDay, setActiveDay] = useState<ActiveDay | null>(null);
const graph = useMemo(() => {
if (days.length === 0) return null;
const firstDate = parseDate(days[0].date);
const positionedDays: PositionedDay[] = days.map((day) => {
const date = parseDate(day.date);
const weekIndex = Math.floor(
(date.getTime() - firstDate.getTime()) / (7 * DAY_IN_MS),
);
const dayIndex = date.getUTCDay();
return {
...day,
dayIndex,
weekIndex,
x: GRID_LEFT + weekIndex * CELL_STEP,
y: GRID_TOP + dayIndex * CELL_STEP,
};
});
const weekCount =
Math.max(...positionedDays.map((day) => day.weekIndex)) + 1;
const width = GRID_LEFT + weekCount * CELL_STEP + CELL_STEP;
const monthFormatter = new Intl.DateTimeFormat(
locale === "tr" ? "tr-TR" : "en-US",
{ month: "short", timeZone: "UTC" },
);
const dateFormatter = new Intl.DateTimeFormat(
locale === "tr" ? "tr-TR" : "en-US",
{ day: "numeric", month: "long", year: "numeric", timeZone: "UTC" },
);
const months: Array<{ label: string; x: number }> = [];
let previousMonth = firstDate.getUTCMonth();
positionedDays.forEach((day, index) => {
const date = parseDate(day.date);
const month = date.getUTCMonth();
if (index > 0 && month !== previousMonth) {
months.push({
label: monthFormatter.format(date).replace(".", ""),
x: day.x,
});
}
previousMonth = month;
});
return { positionedDays, months, width, dateFormatter };
}, [days, locale]);
if (!graph) {
return (
<div className="flex min-h-28 items-center justify-center text-sm text-muted-foreground">
{labels.unavailable}
</div>
);
}
const weekdayLabels =
locale === "tr"
? [
{ index: 1, label: "Pzt" },
{ index: 3, label: "Çar" },
{ index: 5, label: "Cum" },
]
: [
{ index: 1, label: "Mon" },
{ index: 3, label: "Wed" },
{ index: 5, label: "Fri" },
];
function getTooltipLabel(day: GithubContributionDay) {
const date = graph?.dateFormatter.format(parseDate(day.date)) ?? day.date;
if (day.count === 0) return `${date}: ${labels.none}`;
return `${date}: ${day.count} ${
day.count === 1 ? labels.singular : labels.plural
}`;
}
const tooltipX = activeDay
? Math.max(100, Math.min(graph.width - 100, activeDay.x + CELL_SIZE / 2))
: 0;
const tooltipBelow = Boolean(activeDay && activeDay.y < 48);
return (
<div className="overflow-x-auto rounded-sm">
<div
className="relative min-w-[740px]"
style={{ aspectRatio: `${graph.width} / ${GRAPH_HEIGHT}` }}
>
<svg
viewBox={`0 0 ${graph.width} ${GRAPH_HEIGHT}`}
aria-label={labels.calendar}
role="grid"
className="absolute inset-0 h-full w-full"
onMouseLeave={() => setActiveDay(null)}
>
{weekdayLabels.map((day) => (
<text
key={day.index}
x="0"
y={GRID_TOP + day.index * CELL_STEP + 8}
className="fill-muted-foreground text-[9px]"
>
{day.label}
</text>
))}
{graph.months.map((month) => (
<text
key={`${month.label}-${month.x}`}
x={month.x}
y="10"
className="fill-muted-foreground text-[10px]"
>
{month.label}
</text>
))}
{Array.from({ length: 7 }, (_, dayIndex) => (
<g key={dayIndex} role="row">
{graph.positionedDays
.filter((day) => day.dayIndex === dayIndex)
.map((day) => {
const label = getTooltipLabel(day);
return (
<rect
key={day.date}
x={day.x}
y={day.y}
width={CELL_SIZE}
height={CELL_SIZE}
rx="1"
role="gridcell"
tabIndex={0}
aria-label={label}
className={`${LEVEL_CLASSES[day.level]} cursor-default outline-none transition-opacity hover:opacity-75 focus-visible:stroke-foreground focus-visible:stroke-2`}
onMouseEnter={() =>
setActiveDay({ ...day, label })
}
onFocus={() => setActiveDay({ ...day, label })}
onBlur={() => setActiveDay(null)}
/>
);
})}
</g>
))}
</svg>
{activeDay ? (
<div
role="tooltip"
className="pointer-events-none absolute z-10 max-w-56 -translate-x-1/2 rounded-sm border border-border bg-popover px-2 py-1 text-center text-xs text-popover-foreground shadow-md"
style={{
left: `${(tooltipX / graph.width) * 100}%`,
top: `${
((activeDay.y + (tooltipBelow ? CELL_SIZE + 3 : -3)) /
GRAPH_HEIGHT) *
100
}%`,
transform: tooltipBelow
? "translateX(-50%)"
: "translate(-50%, -100%)",
}}
>
{activeDay.label}
</div>
) : null}
</div>
</div>
);
}
+19 -13
View File
@@ -1,4 +1,3 @@
import Image from "next/image";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { import {
@@ -10,6 +9,7 @@ import {
Typography, Typography,
} from "poyraz-ui/atoms"; } from "poyraz-ui/atoms";
import { StaggerContainer, StaggerItem } from "@/components/motion-wrapper"; import { StaggerContainer, StaggerItem } from "@/components/motion-wrapper";
import { GithubContributionGraph } from "@/components/github-contribution-graph";
import { ProjectCardWithPopover } from "@/components/project-card-with-popover"; import { ProjectCardWithPopover } from "@/components/project-card-with-popover";
import { import {
EXTENSIONS, EXTENSIONS,
@@ -18,7 +18,11 @@ import {
WEB_APPS, WEB_APPS,
type ProjectItem, type ProjectItem,
} from "@/data/projects"; } from "@/data/projects";
import { getGithubRepos, getNpmPackages } from "@/lib/project-feeds"; import {
getGithubContributions,
getGithubRepos,
getNpmPackages,
} from "@/lib/project-feeds";
import { getTranslations, getLocale } from "next-intl/server"; import { getTranslations, getLocale } from "next-intl/server";
import { getLocalizedValue } from "@/lib/locale"; import { getLocalizedValue } from "@/lib/locale";
@@ -122,9 +126,10 @@ export async function ProjectsContent() {
const t = await getTranslations("Projects"); const t = await getTranslations("Projects");
const locale = await getLocale(); const locale = await getLocale();
const [repos, npmPackages] = await Promise.all([ const [repos, npmPackages, contributions] = await Promise.all([
getGithubRepos(), getGithubRepos(),
getNpmPackages(), getNpmPackages(),
getGithubContributions(),
]); ]);
const localizeItems = (items: ProjectItem[]): LocalizedProjectItem[] => { const localizeItems = (items: ProjectItem[]): LocalizedProjectItem[] => {
@@ -140,16 +145,17 @@ export async function ProjectsContent() {
return ( return (
<section className="flex h-full flex-col gap-8 overflow-y-auto md:gap-10"> <section className="flex h-full flex-col gap-8 overflow-y-auto md:gap-10">
<Card className="rounded-sm border-border bg-background p-2"> <Card className="rounded-sm border-border bg-background p-2">
<div className="overflow-x-auto rounded-sm"> <GithubContributionGraph
<Image days={contributions}
src="https://ghchart.rshah.org/dc2626/poyrazavsever" locale={locale}
alt="poyrazavsever için GitHub katkı grafiği" labels={{
width={820} calendar: t("contributionCalendar"),
height={120} unavailable: t("contributionUnavailable"),
className="h-auto w-full min-w-[740px]" none: t("noContributions"),
unoptimized singular: t("contributionSingular"),
/> plural: t("contributionPlural"),
</div> }}
/>
</Card> </Card>
<ProjectSection <ProjectSection
+57
View File
@@ -24,6 +24,12 @@ export type GithubActivity = {
createdAt: string; createdAt: string;
}; };
export type GithubContributionDay = {
date: string;
count: number;
level: 0 | 1 | 2 | 3 | 4;
};
type GithubRepoApi = { type GithubRepoApi = {
id: number; id: number;
name: string; name: string;
@@ -136,3 +142,54 @@ export async function getGithubActivity(): Promise<GithubActivity[]> {
return []; return [];
} }
} }
export function parseGithubContributions(
html: string,
): GithubContributionDay[] {
const contributions: GithubContributionDay[] = [];
const cellPattern =
/<td\b([^>]*\bdata-date="[^"]+"[^>]*)><\/td>\s*<tool-tip\b[^>]*>([\s\S]*?)<\/tool-tip>/g;
for (const match of html.matchAll(cellPattern)) {
const attributes = match[1];
const tooltip = match[2].replace(/<[^>]+>/g, "").trim();
const date = attributes.match(/data-date="([^"]+)"/)?.[1];
const levelValue = attributes.match(/data-level="([0-4])"/)?.[1];
const countValue = tooltip.match(/^([\d,]+)\s+contributions?\b/i)?.[1];
if (!date || levelValue === undefined) continue;
contributions.push({
date,
count: countValue ? Number(countValue.replaceAll(",", "")) : 0,
level: Number(levelValue) as GithubContributionDay["level"],
});
}
return contributions.sort((first, second) =>
first.date.localeCompare(second.date),
);
}
export async function getGithubContributions(): Promise<
GithubContributionDay[]
> {
try {
const response = await fetch(
`https://github.com/users/${USERNAME}/contributions`,
{
next: { revalidate: 3600 },
headers: {
Accept: "text/html",
"User-Agent": "portfolio-new",
},
},
);
if (!response.ok) return [];
return parseGithubContributions(await response.text());
} catch {
return [];
}
}
+5
View File
@@ -68,6 +68,11 @@
"technologies": "Technologies", "technologies": "Technologies",
"architecture": "Architecture", "architecture": "Architecture",
"viewCaseStudy": "View case study", "viewCaseStudy": "View case study",
"contributionCalendar": "poyrazavsever GitHub contribution calendar",
"contributionUnavailable": "GitHub contribution data is currently unavailable.",
"noContributions": "No contributions",
"contributionSingular": "contribution",
"contributionPlural": "contributions",
"emptyNpm": "npm API response is currently empty.", "emptyNpm": "npm API response is currently empty.",
"emptyGithub": "GitHub API response is currently empty." "emptyGithub": "GitHub API response is currently empty."
}, },
+5
View File
@@ -68,6 +68,11 @@
"technologies": "Teknolojiler", "technologies": "Teknolojiler",
"architecture": "Mimari", "architecture": "Mimari",
"viewCaseStudy": "Vaka çalışmasını incele", "viewCaseStudy": "Vaka çalışmasını incele",
"contributionCalendar": "poyrazavsever GitHub katkı takvimi",
"contributionUnavailable": "GitHub katkı verisi şu anda alınamıyor.",
"noContributions": "Katkı yok",
"contributionSingular": "katkı",
"contributionPlural": "katkı",
"emptyNpm": "npm API yanıtı şu anda boş.", "emptyNpm": "npm API yanıtı şu anda boş.",
"emptyGithub": "GitHub API yanıtı şu anda boş." "emptyGithub": "GitHub API yanıtı şu anda boş."
}, },