diff --git a/components/github-contribution-graph.tsx b/components/github-contribution-graph.tsx new file mode 100644 index 0000000..a9807f2 --- /dev/null +++ b/components/github-contribution-graph.tsx @@ -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 = { + 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(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 ( +
+ {labels.unavailable} +
+ ); + } + + 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 ( +
+
+ setActiveDay(null)} + > + {weekdayLabels.map((day) => ( + + {day.label} + + ))} + + {graph.months.map((month) => ( + + {month.label} + + ))} + + {Array.from({ length: 7 }, (_, dayIndex) => ( + + {graph.positionedDays + .filter((day) => day.dayIndex === dayIndex) + .map((day) => { + const label = getTooltipLabel(day); + + return ( + + setActiveDay({ ...day, label }) + } + onFocus={() => setActiveDay({ ...day, label })} + onBlur={() => setActiveDay(null)} + /> + ); + })} + + ))} + + + {activeDay ? ( +
+ {activeDay.label} +
+ ) : null} +
+
+ ); +} diff --git a/components/projects-content.tsx b/components/projects-content.tsx index 7a5bf0a..1ef254b 100644 --- a/components/projects-content.tsx +++ b/components/projects-content.tsx @@ -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 (
-
- poyrazavsever için GitHub katkı grafiği -
+
{ return []; } } + +export function parseGithubContributions( + html: string, +): GithubContributionDay[] { + const contributions: GithubContributionDay[] = []; + const cellPattern = + /]*\bdata-date="[^"]+"[^>]*)><\/td>\s*]*>([\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 []; + } +} diff --git a/messages/en.json b/messages/en.json index 9d56fce..3683387 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." }, diff --git a/messages/tr.json b/messages/tr.json index 81b7b19..acc3e7d 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -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ş." },