feat(projects): add interactive GitHub contribution graph
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Icon } from "@iconify/react";
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { StaggerContainer, StaggerItem } from "@/components/motion-wrapper";
|
||||
import { GithubContributionGraph } from "@/components/github-contribution-graph";
|
||||
import { ProjectCardWithPopover } from "@/components/project-card-with-popover";
|
||||
import {
|
||||
EXTENSIONS,
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
WEB_APPS,
|
||||
type ProjectItem,
|
||||
} 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 { getLocalizedValue } from "@/lib/locale";
|
||||
|
||||
@@ -122,9 +126,10 @@ export async function ProjectsContent() {
|
||||
const t = await getTranslations("Projects");
|
||||
const locale = await getLocale();
|
||||
|
||||
const [repos, npmPackages] = await Promise.all([
|
||||
const [repos, npmPackages, contributions] = await Promise.all([
|
||||
getGithubRepos(),
|
||||
getNpmPackages(),
|
||||
getGithubContributions(),
|
||||
]);
|
||||
|
||||
const localizeItems = (items: ProjectItem[]): LocalizedProjectItem[] => {
|
||||
@@ -140,16 +145,17 @@ export async function ProjectsContent() {
|
||||
return (
|
||||
<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">
|
||||
<div className="overflow-x-auto rounded-sm">
|
||||
<Image
|
||||
src="https://ghchart.rshah.org/dc2626/poyrazavsever"
|
||||
alt="poyrazavsever için GitHub katkı grafiği"
|
||||
width={820}
|
||||
height={120}
|
||||
className="h-auto w-full min-w-[740px]"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<GithubContributionGraph
|
||||
days={contributions}
|
||||
locale={locale}
|
||||
labels={{
|
||||
calendar: t("contributionCalendar"),
|
||||
unavailable: t("contributionUnavailable"),
|
||||
none: t("noContributions"),
|
||||
singular: t("contributionSingular"),
|
||||
plural: t("contributionPlural"),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ProjectSection
|
||||
|
||||
@@ -24,6 +24,12 @@ export type GithubActivity = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GithubContributionDay = {
|
||||
date: string;
|
||||
count: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
};
|
||||
|
||||
type GithubRepoApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -136,3 +142,54 @@ export async function getGithubActivity(): Promise<GithubActivity[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
"technologies": "Technologies",
|
||||
"architecture": "Architecture",
|
||||
"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.",
|
||||
"emptyGithub": "GitHub API response is currently empty."
|
||||
},
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
"technologies": "Teknolojiler",
|
||||
"architecture": "Mimari",
|
||||
"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ş.",
|
||||
"emptyGithub": "GitHub API yanıtı şu anda boş."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user