diff --git a/.changeset/release-1781658504.md b/.changeset/release-1781658504.md new file mode 100644 index 0000000..770292e --- /dev/null +++ b/.changeset/release-1781658504.md @@ -0,0 +1,18 @@ +--- +"portfolio": patch +--- + +- 3c2e204: feat(projects): assign random backgrounds from public directory to project previews +- e2feb6f: fix(eslint): fix cascading renders in cover and random background effects +- a49187f: fix(lint): resolve unused import warning in certifications page +- 9396562: style(format): format opensource list and page files with Prettier +- 7a1cd33: feat(layout): integrate RandomBackground backdrop overlay for premium look +- 2c3dbb9: feat(cover): implement theme cycle animation loop and click interaction for ProfileCover +- c14938d: style(images): compress backgrounds directory files to optimized WebP format with ffmpeg +- f37eff0: style(images): compress cover images to optimized WebP format with ffmpeg +- 8e75f23: feat(opensource): support personal repo filtering, pinned order sorting, and interactive header links +- fe9de76: refactor(opensource): extract project card subcomponents to client file for event handler serialization +- 7bb44c3: feat(opensource): implement home preview block and dedicated filters page +- 5fa2a65: feat(opensource): create type definitions, configuration schema and data structures +- f8be192: chore(git): ignore local .protocols directory +- 39ab176: update gitignore and remove protocols folder to project diff --git a/.gitignore b/.gitignore index d83d998..7a223f9 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,4 @@ _bmad-output/ .agents/ .agent/ CODE/ +.protocols/ diff --git a/public/backgrounds/image1.webp b/public/backgrounds/image1.webp new file mode 100644 index 0000000..4d46921 Binary files /dev/null and b/public/backgrounds/image1.webp differ diff --git a/public/backgrounds/image2.webp b/public/backgrounds/image2.webp new file mode 100644 index 0000000..7f33bd4 Binary files /dev/null and b/public/backgrounds/image2.webp differ diff --git a/public/backgrounds/image3.webp b/public/backgrounds/image3.webp new file mode 100644 index 0000000..5e8337f Binary files /dev/null and b/public/backgrounds/image3.webp differ diff --git a/public/backgrounds/image4.webp b/public/backgrounds/image4.webp new file mode 100644 index 0000000..3eb8e35 Binary files /dev/null and b/public/backgrounds/image4.webp differ diff --git a/public/backgrounds/image5.webp b/public/backgrounds/image5.webp new file mode 100644 index 0000000..5620d34 Binary files /dev/null and b/public/backgrounds/image5.webp differ diff --git a/public/backgrounds/image6.webp b/public/backgrounds/image6.webp new file mode 100644 index 0000000..6f598d8 Binary files /dev/null and b/public/backgrounds/image6.webp differ diff --git a/public/backgrounds/image7.webp b/public/backgrounds/image7.webp new file mode 100644 index 0000000..225d8b0 Binary files /dev/null and b/public/backgrounds/image7.webp differ diff --git a/public/backgrounds/image8.webp b/public/backgrounds/image8.webp new file mode 100644 index 0000000..fe4be2f Binary files /dev/null and b/public/backgrounds/image8.webp differ diff --git a/public/covers/cover-dark.webp b/public/covers/cover-dark.webp deleted file mode 100644 index 7f7a228..0000000 Binary files a/public/covers/cover-dark.webp and /dev/null differ diff --git a/public/covers/cover-light.webp b/public/covers/cover-light.webp deleted file mode 100644 index eea0be7..0000000 Binary files a/public/covers/cover-light.webp and /dev/null differ diff --git a/public/covers/cover1.webp b/public/covers/cover1.webp new file mode 100644 index 0000000..df51375 Binary files /dev/null and b/public/covers/cover1.webp differ diff --git a/public/covers/cover2.webp b/public/covers/cover2.webp new file mode 100644 index 0000000..7e2b893 Binary files /dev/null and b/public/covers/cover2.webp differ diff --git a/public/covers/cover4.webp b/public/covers/cover4.webp new file mode 100644 index 0000000..4ae8ddc Binary files /dev/null and b/public/covers/cover4.webp differ diff --git a/public/covers/cover5.webp b/public/covers/cover5.webp new file mode 100644 index 0000000..b06bd98 Binary files /dev/null and b/public/covers/cover5.webp differ diff --git a/public/covers/footer1.webp b/public/covers/footer1.webp new file mode 100644 index 0000000..58c6527 Binary files /dev/null and b/public/covers/footer1.webp differ diff --git a/src/app/(app)/(pages)/certifications/page.tsx b/src/app/(app)/(pages)/certifications/page.tsx index 3eb65d0..08d3564 100644 --- a/src/app/(app)/(pages)/certifications/page.tsx +++ b/src/app/(app)/(pages)/certifications/page.tsx @@ -7,7 +7,6 @@ import { ArrowLeftIcon, AwardIcon, ExternalLinkIcon } from "lucide-react" import type { Doc } from "@/types/document" import { X_HANDLE } from "@/config/site" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { BlocksSeparator } from "@/components/blocks-separator" diff --git a/src/app/(app)/(pages)/opensource/components/opensource-list.tsx b/src/app/(app)/(pages)/opensource/components/opensource-list.tsx new file mode 100644 index 0000000..fb79fee --- /dev/null +++ b/src/app/(app)/(pages)/opensource/components/opensource-list.tsx @@ -0,0 +1,429 @@ +"use client" + +import { useMemo, useState } from "react" +import { CONTRIBUTION_CONFIG } from "@/data/portfolio/opensource-contributions" +import { format } from "date-fns" +import { + CheckCircle2, + CircleDot, + Compass, + GitMerge, + GitPullRequest, + Pin, + Search, + X, +} from "lucide-react" + +import type { GitHubContribution } from "@/types/opensource-contributions" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group" +import { + Collapsible, + CollapsibleChevronsIcon, +} from "@/components/base/collapsible-animated" +import { + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/base/ui/collapsible" +import { Panel, PanelContent } from "@/components/panel" +import { TechTag } from "@/components/tech-tag" + +// Helper to map repo to languages/skills +function getSkillsForRepo(repo: string): string[] { + const name = repo.toLowerCase() + if (name.includes("nestjs/nest") || name.includes("nestjs/")) { + return ["TypeScript", "Node.js", "NestJS"] + } + if (name.includes("vendurehq/vendure") || name.includes("vendure/")) { + return ["TypeScript", "Node.js", "GraphQL", "PostgreSQL"] + } + if (name.includes("shoperzz/shoperzz") || name.includes("shoperzz/")) { + return ["TypeScript", "Next.js", "NestJS", "GraphQL", "React"] + } + if (name.includes("wistant/portfolio") || name.includes("portfolio")) { + return ["TypeScript", "Next.js", "React", "Tailwind CSS"] + } + return ["TypeScript"] +} + +export function OpenSourceList({ + contributions, +}: { + contributions: GitHubContribution[] +}) { + const [search, setSearch] = useState("") + const [typeFilter, setTypeFilter] = useState<"all" | "pr" | "issue">("all") + const [statusFilter, setStatusFilter] = useState< + "all" | "open" | "merged" | "closed" + >("all") + + // Filter contributions: exclude own repos unless included or pinned + const filteredContributions = useMemo(() => { + const username = CONTRIBUTION_CONFIG.username + + return contributions.filter((item) => { + // 1. Exclude own repositories unless included or pinned + const repoOwner = item.repository.split("/")[0] + const isOwnRepo = repoOwner.toLowerCase() === username.toLowerCase() + const isIncluded = CONTRIBUTION_CONFIG.includePersonalRepos?.some( + (r) => r.toLowerCase() === item.repository.toLowerCase() + ) + + if (isOwnRepo && !isIncluded && !item.isPinned) return false + + // 2. Search matches + const matchesSearch = + item.title.toLowerCase().includes(search.toLowerCase()) || + item.repository.toLowerCase().includes(search.toLowerCase()) + + // 3. Type matches + const matchesType = typeFilter === "all" || item.type === typeFilter + + // 4. Status matches + const matchesStatus = + statusFilter === "all" || item.status === statusFilter + + return matchesSearch && matchesType && matchesStatus + }) + }, [contributions, search, typeFilter, statusFilter]) + + // Group by repository and sort items in each group (pinned first) + const groupedContributions = useMemo(() => { + const groups: Record< + string, + { repoUrl: string; items: GitHubContribution[]; skills: string[] } + > = {} + filteredContributions.forEach((item) => { + if (!groups[item.repository]) { + groups[item.repository] = { + repoUrl: item.repositoryUrl, + items: [], + skills: getSkillsForRepo(item.repository), + } + } + groups[item.repository].items.push(item) + }) + + // Sort items inside each repo group: pinned first, then date desc + Object.values(groups).forEach((g) => { + g.items.sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1 + if (!a.isPinned && b.isPinned) return 1 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) + }) + + return groups + }, [filteredContributions]) + + // Calculate stats based on filtered contributions + const stats = useMemo(() => { + const total = filteredContributions.length + const prs = filteredContributions.filter((c) => c.type === "pr").length + const issues = filteredContributions.filter( + (c) => c.type === "issue" + ).length + const merged = filteredContributions.filter( + (c) => c.status === "merged" + ).length + + return { total, prs, issues, merged } + }, [filteredContributions]) + + return ( +
+ {/* Metrics Dashboard */} +
+
+
+ Total Contributions +
+
+ {stats.total} +
+
+
+
+ Pull Requests +
+
+ {stats.prs} +
+
+
+
+ Issues Opened +
+
+ {stats.issues} +
+
+
+
+ Merged PRs +
+
+ {stats.merged} +
+
+
+ + {/* Filters Panel */} + + +
+
+ + setSearch(e.target.value)} + /> + + + + {search && ( + + setSearch("")} + > + + + + )} + +
+
+ +
+ {/* Type selector */} +
+ + Type: + +
+ {(["all", "pr", "issue"] as const).map((type) => ( + + ))} +
+
+ + {/* Status selector */} +
+ + Status: + +
+ {(["all", "open", "merged", "closed"] as const).map( + (status) => { + if (typeFilter === "issue" && status === "merged") + return null + return ( + + ) + } + )} +
+
+
+
+
+ + {/* Result list grouped by Repository with Collapsible */} +
+ {filteredContributions.length === 0 ? ( +
+ +

+ No contributions found +

+

+ Try adjusting your filters or search terms. +

+
+ ) : ( +
+ {Object.entries(groupedContributions).map( + ([repoName, { items, skills }]) => { + const owner = repoName.split("/")[0] + return ( +
+ + +
+ +
+ +
+
+ + {/* Project technologies */} + {skills.length > 0 && ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ + +
+ {items.map((contrib) => ( + + ))} +
+
+
+
+ ) + } + )} +
+ )} +
+
+ ) +} + +function ContributionRow({ contrib }: { contrib: GitHubContribution }) { + const isPR = contrib.type === "pr" + const isMerged = contrib.status === "merged" + const isOpen = contrib.status === "open" + + let Icon = CircleDot + let iconColor = "text-green-600 dark:text-green-400 bg-green-500/10" + + if (isPR) { + if (isMerged) { + Icon = GitMerge + iconColor = "text-purple-600 dark:text-purple-400 bg-purple-500/10" + } else if (isOpen) { + Icon = GitPullRequest + iconColor = "text-green-600 dark:text-green-400 bg-green-500/10" + } else { + Icon = GitPullRequest + iconColor = "text-red-600 dark:text-red-400 bg-red-500/10" + } + } else { + if (isOpen) { + Icon = CircleDot + iconColor = "text-green-600 dark:text-green-400 bg-green-500/10" + } else { + Icon = CheckCircle2 + iconColor = "text-red-600 dark:text-red-400 bg-red-500/10" + } + } + + return ( +
+
+ +
+ +
+
+ #{contrib.number} + + {format(new Date(contrib.createdAt), "MMM d, yyyy")} + {contrib.isPinned && ( + <> + + + + Pinned + + + )} +
+ + + {contrib.title} + + + {contrib.labels && contrib.labels.length > 0 && ( +
+ {contrib.labels.map((label) => ( + + {label.name} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/app/(app)/(pages)/opensource/page.tsx b/src/app/(app)/(pages)/opensource/page.tsx new file mode 100644 index 0000000..d8931e2 --- /dev/null +++ b/src/app/(app)/(pages)/opensource/page.tsx @@ -0,0 +1,66 @@ +import { Suspense } from "react" +import type { Metadata } from "next" + +import { X_HANDLE } from "@/config/site" +import { getOpenSourceContributions } from "@/lib/opensource-contributions" +import { + PageHeading, + PageHeadingTagline, + PageHeadingTitle, +} from "@/components/page-heading" + +import { OpenSourceList } from "./components/opensource-list" + +const title = "Open Source Contributions" +const description = + "Showcasing my pull requests, issues, and contributions to public open source projects." + +const ogImage = `/og/simple?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}` + +export const metadata: Metadata = { + title, + description, + alternates: { + canonical: "/opensource", + }, + openGraph: { + url: "/opensource", + type: "website", + images: { + url: ogImage, + width: 1200, + height: 630, + alt: title, + }, + }, + twitter: { + card: "summary_large_image", + site: X_HANDLE, + creator: X_HANDLE, + images: [ogImage], + }, +} + +export default async function Page() { + const contributions = await getOpenSourceContributions() + + return ( +
+ + Open Source + + Public contributions, bug fixes, and feature integrations across the + ecosystem. + + + +
+ + Loading contributions...
}> + + + +
+
+ ) +} diff --git a/src/app/(app)/(pages)/projects/components/project-card-preview.tsx b/src/app/(app)/(pages)/projects/components/project-card-preview.tsx index 7366a5e..60c04f8 100644 --- a/src/app/(app)/(pages)/projects/components/project-card-preview.tsx +++ b/src/app/(app)/(pages)/projects/components/project-card-preview.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import Image from "next/image" import { Pin, Star } from "lucide-react" import { motion } from "motion/react" @@ -8,6 +8,17 @@ import { motion } from "motion/react" import { formatStars } from "@/lib/github" import { Icons } from "@/components/icons" +const BACKGROUNDS = [ + "/backgrounds/image1.webp", + "/backgrounds/image2.webp", + "/backgrounds/image3.webp", + "/backgrounds/image4.webp", + "/backgrounds/image5.webp", + "/backgrounds/image6.webp", + "/backgrounds/image7.webp", + "/backgrounds/image8.webp", +] + interface ProjectCardPreviewProps { title: string projectImage?: string @@ -209,6 +220,13 @@ export function ProjectCardPreview({ }: ProjectCardPreviewProps) { const [extractedColor, setExtractedColor] = useState(null) + const selectedBg = useMemo(() => { + const hash = projectId + .split("") + .reduce((acc, char) => acc + char.charCodeAt(0), 0) + return BACKGROUNDS[hash % BACKGROUNDS.length] + }, [projectId]) + const cardVariants = { initial: { y: 2 }, hover: { y: 10 }, @@ -272,30 +290,17 @@ export function ProjectCardPreview({
)} - {/* Background Gradient/Pattern/Glow */} - {activeColor && dynamicBackground ? ( - + {title} - ) : isCssGradient ? ( -
- ) : ( -
- {title} -
- )} +
{/* Foreground Project Mockup Image or Logo fallback */} {projectImage ? ( diff --git a/src/app/(app)/components/home/profile/cover.tsx b/src/app/(app)/components/home/profile/cover.tsx index 5e16b6c..da488ba 100644 --- a/src/app/(app)/components/home/profile/cover.tsx +++ b/src/app/(app)/components/home/profile/cover.tsx @@ -1,19 +1,40 @@ "use client" -import { useRef } from "react" +import { useEffect, useRef, useState } from "react" import Image from "next/image" import { useTheme } from "next-themes" import { cn } from "@/lib/utils" import { BannerParticles } from "@/components/banner-particles" +const COVERS = [ + "/covers/cover1.webp", + "/covers/cover2.webp", + "/covers/cover4.webp", + "/covers/cover5.webp", +] + export function ProfileCover() { const containerRef = useRef(null) + const isFirstRender = useRef(true) + const [coverIndex, setCoverIndex] = useState(0) const { resolvedTheme } = useTheme() const theme = resolvedTheme === "dark" ? "dark" : "light" - const imageSrc = - theme === "dark" ? "/covers/cover-dark.webp" : "/covers/cover-light.webp" + + // Cycle cover image in a loop when theme changes, avoiding synchronous renders during initial mount + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + const handle = requestAnimationFrame(() => { + setCoverIndex((prev) => (prev + 1) % COVERS.length) + }) + return () => cancelAnimationFrame(handle) + }, [resolvedTheme]) + + const imageSrc = COVERS[coverIndex] const maskStyle = { WebkitMaskImage: @@ -24,22 +45,29 @@ export function ProfileCover() { maskComposite: "intersect", } + const handleCoverClick = () => { + // Manually cycle through covers when clicked + setCoverIndex((prev) => (prev + 1) % COVERS.length) + } + return (
+ + + + + {skills.length > 0 && ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ + +
+ {displayItems.map((contrib) => ( + + ))} + {items.length > 3 && ( + + Show {items.length - 3} more contributions in this project + + )} +
+
+
+
+ ) +} + +function ContributionRow({ contrib }: { contrib: GitHubContribution }) { + const isPR = contrib.type === "pr" + const isMerged = contrib.status === "merged" + const isOpen = contrib.status === "open" + + let Icon = CircleDot + let iconColor = "text-green-600 dark:text-green-400 bg-green-500/10" + + if (isPR) { + if (isMerged) { + Icon = GitMerge + iconColor = "text-purple-600 dark:text-purple-400 bg-purple-500/10" + } else if (isOpen) { + Icon = GitPullRequest + iconColor = "text-green-600 dark:text-green-400 bg-green-500/10" + } else { + Icon = GitPullRequest + iconColor = "text-red-600 dark:text-red-400 bg-red-500/10" + } + } else { + if (isOpen) { + Icon = CircleDot + iconColor = "text-green-600 dark:text-green-400 bg-green-500/10" + } else { + Icon = CheckCircle2 + iconColor = "text-red-600 dark:text-red-400 bg-red-500/10" + } + } + + return ( +
+
+ +
+ +
+
+ #{contrib.number} + + + {formatDistanceToNow(new Date(contrib.createdAt), { + addSuffix: true, + })} + + {contrib.isPinned && ( + <> + + + + Pinned + + + )} +
+ + + {contrib.title} + +
+
+ ) +} diff --git a/src/app/(app)/components/home/sections/opensource-contributions/index.tsx b/src/app/(app)/components/home/sections/opensource-contributions/index.tsx new file mode 100644 index 0000000..f90c718 --- /dev/null +++ b/src/app/(app)/components/home/sections/opensource-contributions/index.tsx @@ -0,0 +1,109 @@ +import Link from "next/link" +import { CONTRIBUTION_CONFIG } from "@/data/portfolio/opensource-contributions" +import { ArrowRight } from "lucide-react" + +import type { GitHubContribution } from "@/types/opensource-contributions" +import { getOpenSourceContributions } from "@/lib/opensource-contributions" +import { + Panel, + PanelContent, + PanelHeader, + PanelTitle, +} from "@/components/panel" + +import { ProjectCard } from "./client" + +// Helper to map repo to languages/skills +function getSkillsForRepo(repo: string): string[] { + const name = repo.toLowerCase() + if (name.includes("nestjs/nest") || name.includes("nestjs/")) { + return ["TypeScript", "Node.js", "NestJS"] + } + if (name.includes("vendurehq/vendure") || name.includes("vendure/")) { + return ["TypeScript", "Node.js", "GraphQL"] + } + if (name.includes("shoperzz/shoperzz") || name.includes("shoperzz/")) { + return ["TypeScript", "Next.js", "NestJS", "GraphQL"] + } + if (name.includes("wistant/portfolio") || name.includes("portfolio")) { + return ["TypeScript", "Next.js", "React"] + } + return ["TypeScript"] +} + +export async function OpenSourceContributions() { + const contributions = await getOpenSourceContributions() + const username = CONTRIBUTION_CONFIG.username + + // Filter out personal repositories unless explicitly included or pinned + const filtered = contributions.filter((item) => { + const repoOwner = item.repository.split("/")[0] + const isOwnRepo = repoOwner.toLowerCase() === username.toLowerCase() + const isIncluded = CONTRIBUTION_CONFIG.includePersonalRepos?.some( + (r) => r.toLowerCase() === item.repository.toLowerCase() + ) + return !isOwnRepo || isIncluded || item.isPinned + }) + + // Group and sort by repository (pinned items first inside groups) + const groups: Record< + string, + { items: GitHubContribution[]; skills: string[] } + > = {} + filtered.forEach((item) => { + if (!groups[item.repository]) { + groups[item.repository] = { + items: [], + skills: getSkillsForRepo(item.repository), + } + } + groups[item.repository].items.push(item) + }) + + // Sort items in each group: pinned first, then by date desc + Object.values(groups).forEach((g) => { + g.items.sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1 + if (!a.isPinned && b.isPinned) return 1 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) + }) + + const displayGroups = Object.entries(groups).slice(0, 3) + + if (filtered.length === 0) { + return null + } + + return ( + + + Open Source Contributions 🌐 + + + +
+ {displayGroups.map(([repoName, { items, skills }], index) => ( + + ))} +
+ +
+ + View all contributions + + +
+
+
+ ) +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index b7b0b1f..c055dc6 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -2,14 +2,12 @@ import type { Metadata } from "next" import { getDocsByCategory } from "@/data/doc/documents" import { SPONSORS } from "@/data/sponsor-data" -import { cn } from "@/lib/utils" import { BlocksSeparator } from "@/components/blocks-separator" import { Blog } from "./(pages)/blog/components" import { Certifications } from "./(pages)/certifications/components" import { Projects } from "./(pages)/projects/components" import { ProfileCover } from "./components/home/profile/cover" -import { ProfileHeader } from "./components/home/profile/header" import { SocialLinks } from "./components/home/profile/social" import { About } from "./components/home/sections/about" import { Experiences } from "./components/home/sections/experiences" @@ -31,13 +29,14 @@ export default function HomePage() { <>
- + {/**/} + {/**/} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index aa10b7b..29e6c41 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import type { WebSite, WithContext } from "schema-dts" import { META_THEME_COLORS, SITE_INFO, X_HANDLE } from "@/config/site" import { fontVariables } from "@/lib/fonts" import { Providers } from "@/components/providers" +import { RandomBackground } from "@/components/random-background" function getWebSiteJsonLd(): WithContext { return { @@ -140,6 +141,7 @@ export default function RootLayout({ + {children} diff --git a/src/components/random-background.tsx b/src/components/random-background.tsx new file mode 100644 index 0000000..353db00 --- /dev/null +++ b/src/components/random-background.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" + +const BACKGROUNDS = [ + "/backgrounds/image1.webp", + "/backgrounds/image2.webp", + "/backgrounds/image3.webp", + "/backgrounds/image4.webp", + "/backgrounds/image5.webp", + "/backgrounds/image6.webp", + "/backgrounds/image7.webp", + "/backgrounds/image8.webp", +] + +export function RandomBackground() { + const [bg, setBg] = useState("") + + useEffect(() => { + // Pick a random background image on client mount, wrapped in requestAnimationFrame to avoid synchronous render warning + const randomIndex = Math.floor(Math.random() * BACKGROUNDS.length) + const handle = requestAnimationFrame(() => { + setBg(BACKGROUNDS[randomIndex]) + }) + return () => cancelAnimationFrame(handle) + }, []) + + if (!bg) return null + + return ( +
+ ) +} diff --git a/src/config/site.ts b/src/config/site.ts index 0b72274..6fd3d1a 100644 --- a/src/config/site.ts +++ b/src/config/site.ts @@ -29,6 +29,10 @@ export const MAIN_NAV: NavItem[] = [ title: "Certifications", href: "/certifications", }, + { + title: "Open Source", + href: "/opensource", + }, ...(SPONSORS.length > 0 ? [ { diff --git a/src/data/portfolio/opensource-contributions.ts b/src/data/portfolio/opensource-contributions.ts new file mode 100644 index 0000000..b563bd6 --- /dev/null +++ b/src/data/portfolio/opensource-contributions.ts @@ -0,0 +1,126 @@ +import type { + ContributionConfig, + GitHubContribution, +} from "@/types/opensource-contributions" +import { GITHUB_USERNAME } from "@/config/site" + +export const CONTRIBUTION_CONFIG: ContributionConfig = { + username: GITHUB_USERNAME, + includePersonalRepos: [ + "wistant/portfolio", // Explicitly show this personal repository + ], + pinnedPRs: [ + { owner: "shoperzz", repo: "shoperzz", number: 42 }, + { owner: "wistant", repo: "portfolio", number: 8 }, + ], +} + +export const MOCK_CONTRIBUTIONS: GitHubContribution[] = [ + { + id: 1, + title: + "feat(core): implement high-performance cache interceptor for distributed systems", + url: "https://github.com/nestjs/nest/pull/42012", + repository: "nestjs/nest", + repositoryUrl: "https://github.com/nestjs/nest", + type: "pr", + status: "merged", + createdAt: "2026-05-10T14:22:00Z", + closedAt: "2026-05-12T10:00:00Z", + number: 42012, + labels: [ + { name: "type: feature", color: "0e8a16" }, + { name: "status: merged", color: "6f42c1" }, + ], + }, + { + id: 2, + title: + "fix(graphql): resolve database connection pooling issues under heavy loads", + url: "https://github.com/vendurehq/vendure/pull/189", + repository: "vendurehq/vendure", + repositoryUrl: "https://github.com/vendurehq/vendure", + type: "pr", + status: "merged", + createdAt: "2026-04-15T09:15:00Z", + closedAt: "2026-04-16T16:30:00Z", + number: 189, + labels: [ + { name: "bug", color: "d73a4a" }, + { name: "GraphQL", color: "a2eeef" }, + ], + }, + { + id: 3, + title: + "feat(admin): implement complete role-based access control dashboard UI", + url: "https://github.com/shoperzz/shoperzz/pull/42", + repository: "shoperzz/shoperzz", + repositoryUrl: "https://github.com/shoperzz/shoperzz", + type: "pr", + status: "open", + createdAt: "2026-06-01T11:00:00Z", + number: 42, + labels: [ + { name: "enhancement", color: "a2eeef" }, + { name: "frontend", color: "c5def5" }, + ], + }, + { + id: 4, + title: "perf(core): optimize memory leak in hot reload module", + url: "https://github.com/nestjs/nest/pull/41982", + repository: "nestjs/nest", + repositoryUrl: "https://github.com/nestjs/nest", + type: "pr", + status: "closed", + createdAt: "2026-03-20T08:00:00Z", + closedAt: "2026-03-22T09:00:00Z", + number: 41982, + labels: [ + { name: "performance", color: "d876e3" }, + { name: "wontfix", color: "ffffff" }, + ], + }, + { + id: 5, + title: "docs(llms): update general index endpoints and structure manifests", + url: "https://github.com/wistant/portfolio/pull/8", + repository: "wistant/portfolio", + repositoryUrl: "https://github.com/wistant/portfolio", + type: "pr", + status: "merged", + createdAt: "2026-06-16T18:00:00Z", + closedAt: "2026-06-16T19:30:00Z", + number: 8, + labels: [{ name: "documentation", color: "0075ca" }], + }, + { + id: 6, + title: "issue: core module crashes on custom multi-tenant configuration", + url: "https://github.com/shoperzz/shoperzz/issues/12", + repository: "shoperzz/shoperzz", + repositoryUrl: "https://github.com/shoperzz/shoperzz", + type: "issue", + status: "open", + createdAt: "2026-06-05T12:00:00Z", + number: 12, + labels: [ + { name: "bug", color: "d73a4a" }, + { name: "critical", color: "e11d48" }, + ], + }, + { + id: 7, + title: "issue: support dynamic schema extensions via graphql-tools", + url: "https://github.com/vendurehq/vendure/issues/88", + repository: "vendurehq/vendure", + repositoryUrl: "https://github.com/vendurehq/vendure", + type: "issue", + status: "closed", + createdAt: "2026-02-10T10:00:00Z", + closedAt: "2026-02-15T18:00:00Z", + number: 88, + labels: [{ name: "feature request", color: "fbca04" }], + }, +] diff --git a/src/lib/opensource-contributions.ts b/src/lib/opensource-contributions.ts new file mode 100644 index 0000000..8a122c0 --- /dev/null +++ b/src/lib/opensource-contributions.ts @@ -0,0 +1,193 @@ +import { execSync } from "node:child_process" +import { unstable_cache } from "next/cache" +import { + CONTRIBUTION_CONFIG, + MOCK_CONTRIBUTIONS, +} from "@/data/portfolio/opensource-contributions" + +import type { GitHubContribution } from "@/types/opensource-contributions" + +interface GitHubSearchItem { + id: number + title: string + html_url: string + number: number + state: string + created_at: string + closed_at?: string + repository_url: string + pull_request?: { + merged_at?: string | null + } + labels?: Array<{ name: string; color: string }> +} + +interface GitHubSearchResponse { + items: GitHubSearchItem[] +} + +async function fetchOpenSourceContributions(): Promise< + GitHubContribution[] | null +> { + const { username } = CONTRIBUTION_CONFIG + // Search for issues and PRs created by the user + const query = `author:${username}` + const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&per_page=100` + + const headers: Record = { + "User-Agent": "wistant-portfolio", + Accept: "application/vnd.github.v3+json", + } + + if (process.env.GITHUB_TOKEN) { + headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}` + } + + // Helper to extract repo name from repository_url: "https://api.github.com/repos/owner/repo" -> "owner/repo" + const getRepoName = (repoUrl: string): string => { + const parts = repoUrl.split("/repos/") + return parts.length > 1 ? parts[1] : "" + } + + // Helper to get repo web URL from name + const getRepoWebUrl = (repoName: string): string => { + return `https://github.com/${repoName}` + } + + // 1. Try native fetch first + try { + const timeout = 6000 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + const res = await fetch(url, { + headers, + signal: controller.signal, + next: { revalidate: 3600 }, // Cache at fetch level for 1 hour + }) + clearTimeout(timeoutId) + + if (res.ok) { + const data = (await res.json()) as GitHubSearchResponse + if (data.items) { + return mapGitHubItems(data.items, getRepoName, getRepoWebUrl) + } + } + } catch { + // Fail silently and try curl fallback + } + + // 2. Try curl fallback (handles Node.js DNS/timeout quirks in some environments) + try { + const authHeader = process.env.GITHUB_TOKEN + ? ` -H "Authorization: token ${process.env.GITHUB_TOKEN}"` + : "" + const output = execSync( + `curl -s -H "User-Agent: wistant-portfolio" -H "Accept: application/vnd.github.v3+json"${authHeader} "${url}"`, + { + encoding: "utf8", + timeout: 6000, + } + ) + const data = JSON.parse(output) as GitHubSearchResponse + if (data.items) { + return mapGitHubItems(data.items, getRepoName, getRepoWebUrl) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.warn( + `All methods to fetch GitHub search contributions failed (${message}). Using mock data.` + ) + } + + return null +} + +function mapGitHubItems( + items: GitHubSearchItem[], + getRepoName: (url: string) => string, + getRepoWebUrl: (name: string) => string +): GitHubContribution[] { + return items.map((item) => { + const repoName = getRepoName(item.repository_url) + const [owner, repo] = repoName.split("/") + const isPR = !!item.pull_request + let status: "open" | "merged" | "closed" = "open" + + if (isPR) { + if (item.pull_request?.merged_at) { + status = "merged" + } else if (item.state === "closed") { + status = "closed" + } + } else { + status = item.state === "open" ? "open" : "closed" + } + + const isPinned = CONTRIBUTION_CONFIG.pinnedPRs?.some( + (pinned) => + pinned.owner.toLowerCase() === owner.toLowerCase() && + pinned.repo.toLowerCase() === repo.toLowerCase() && + pinned.number === item.number + ) + + return { + id: item.id, + title: item.title, + url: item.html_url, + repository: repoName, + repositoryUrl: getRepoWebUrl(repoName), + type: isPR ? "pr" : "issue", + status, + createdAt: item.created_at, + closedAt: item.closed_at, + number: item.number, + labels: item.labels?.map((label) => ({ + name: label.name, + color: label.color, + })), + isPinned, + } + }) +} + +let devCachePromise: Promise | null = null + +function getMockContributionsWithPinned(): GitHubContribution[] { + return MOCK_CONTRIBUTIONS.map((item) => { + const [owner, repo] = item.repository.split("/") + const isPinned = CONTRIBUTION_CONFIG.pinnedPRs?.some( + (pinned) => + pinned.owner.toLowerCase() === owner.toLowerCase() && + pinned.repo.toLowerCase() === repo.toLowerCase() && + pinned.number === item.number + ) + return { ...item, isPinned } + }) +} + +async function getCachedDevContributions(): Promise { + if (!devCachePromise) { + devCachePromise = fetchOpenSourceContributions() + } + const data = await devCachePromise + if (data) { + return data + } + devCachePromise = null + return getMockContributionsWithPinned() +} + +async function getProductionContributions(): Promise { + const data = await fetchOpenSourceContributions() + return data || getMockContributionsWithPinned() +} + +export const getOpenSourceContributions = + process.env.NODE_ENV === "development" + ? getCachedDevContributions + : unstable_cache( + getProductionContributions, + ["opensource-contributions"], + { revalidate: 86400 } // Cache for 1 day in production + ) diff --git a/src/types/opensource-contributions.ts b/src/types/opensource-contributions.ts new file mode 100644 index 0000000..88ef3cf --- /dev/null +++ b/src/types/opensource-contributions.ts @@ -0,0 +1,20 @@ +export interface GitHubContribution { + id: number + title: string + url: string + repository: string + repositoryUrl: string + type: "pr" | "issue" + status: "open" | "merged" | "closed" + createdAt: string + closedAt?: string + number: number + labels?: Array<{ name: string; color: string }> + isPinned?: boolean +} + +export interface ContributionConfig { + username: string + includePersonalRepos?: string[] + pinnedPRs?: Array<{ owner: string; repo: string; number: number }> +}