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 { 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
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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ş."
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user