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 ? (
-
+
- ) : isCssGradient ? (
-
- ) : (
-
-
-
- )}
+
{/* 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 }>
+}