diff --git a/app/api/leetcode/route.ts b/app/api/leetcode/route.ts
new file mode 100644
index 0000000..58efd30
--- /dev/null
+++ b/app/api/leetcode/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server"
+
+import { fetchLeetCodeContributions, parseLeetCodeUsername } from "@/lib/leetcode"
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const rawInput = searchParams.get("user") ?? searchParams.get("username") ?? ""
+
+ const username = parseLeetCodeUsername(rawInput)
+ if (!username) {
+ return NextResponse.json(
+ { error: "Enter a valid LeetCode username or profile link." },
+ { status: 400 }
+ )
+ }
+
+ try {
+ const result = await fetchLeetCodeContributions(username)
+ return NextResponse.json(result)
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to load contributions."
+ const status = message.includes("not found") ? 404 : 500
+ return NextResponse.json({ error: message }, { status })
+ }
+}
diff --git a/app/leetcode/[username]/page.tsx b/app/leetcode/[username]/page.tsx
new file mode 100644
index 0000000..11a0baa
--- /dev/null
+++ b/app/leetcode/[username]/page.tsx
@@ -0,0 +1,24 @@
+import { LeetCodeGraphLoader } from "@/components/leetcode-graph/leetcode-graph-loader"
+import { parseLeetCodeUsername } from "@/lib/leetcode"
+import { notFound } from "next/navigation"
+
+type LeetCodeUserPageProps = {
+ params: Promise<{ username: string }>
+}
+
+export default async function LeetCodeUserPage({
+ params,
+}: LeetCodeUserPageProps) {
+ const { username } = await params
+ const parsed = parseLeetCodeUsername(username)
+
+ if (!parsed) {
+ notFound()
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/app/leetcode/page.tsx b/app/leetcode/page.tsx
new file mode 100644
index 0000000..225a136
--- /dev/null
+++ b/app/leetcode/page.tsx
@@ -0,0 +1,9 @@
+import { LeetCodeGraphLoader } from "@/components/leetcode-graph/leetcode-graph-loader"
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
diff --git a/app/page.tsx b/app/page.tsx
index c1e331c..b3acd01 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,9 +1,18 @@
+import Link from "next/link"
+
import { ContributionGraphLoader } from "@/components/contribution-graph/contribution-graph-loader"
export default function Page() {
return (
+
+
+ Check your leetcode here
+
)
}
diff --git a/components/leetcode-graph/leetcode-graph-loader.tsx b/components/leetcode-graph/leetcode-graph-loader.tsx
new file mode 100644
index 0000000..aefde96
--- /dev/null
+++ b/components/leetcode-graph/leetcode-graph-loader.tsx
@@ -0,0 +1,27 @@
+import { Suspense } from "react"
+
+import { LeetCodeGraph } from "@/components/leetcode-graph/leetcode-graph"
+
+type LeetCodeGraphLoaderProps = {
+ initialUsername?: string
+}
+
+function LeetCodeGraphFallback() {
+ return (
+
+
+
Loading contribution graph...
+
+
+ )
+}
+
+export function LeetCodeGraphLoader({
+ initialUsername,
+}: LeetCodeGraphLoaderProps) {
+ return (
+ }>
+
+
+ )
+}
diff --git a/components/leetcode-graph/leetcode-graph.tsx b/components/leetcode-graph/leetcode-graph.tsx
new file mode 100644
index 0000000..12d88b9
--- /dev/null
+++ b/components/leetcode-graph/leetcode-graph.tsx
@@ -0,0 +1,378 @@
+"use client"
+
+import { Check, Download, Loader2, Share2 } from "lucide-react"
+import dynamic from "next/dynamic"
+import { usePathname, useRouter } from "next/navigation"
+import { parseAsString, useQueryState } from "nuqs"
+import {
+ FormEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+
+import { LeetCodeSearchPanel } from "@/components/leetcode-search-panel"
+import { ProfileAnalysisPanel } from "@/components/profile-analysis"
+import { SidebarMenu } from "@/components/sidebar-menu"
+import { Button } from "@/components/ui/button"
+import type { LeetCodeResult } from "@/lib/leetcode"
+import { parseLeetCodeUsername } from "@/lib/leetcode"
+import {
+ buildChartExportImage,
+ type ChartImageExportResult,
+ exportChartImage,
+} from "@/lib/share-chart-image"
+
+const ContributionScene = dynamic(
+ () =>
+ import("@/components/contribution-graph/contribution-scene").then(
+ (module) => module.ContributionScene
+ ),
+ {
+ ssr: false,
+ loading: () => (
+
+
+
Preparing 3D scene...
+
+ ),
+ }
+)
+
+type LeetCodeGraphProps = {
+ initialUsername?: string
+}
+
+export function LeetCodeGraph({ initialUsername }: LeetCodeGraphProps) {
+ const router = useRouter()
+ const pathname = usePathname()
+ const [queryUser, setQueryUser] = useQueryState(
+ "user",
+ parseAsString.withDefault("")
+ )
+ const [input, setInput] = useState("")
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [profile, setProfile] = useState(null)
+ const [copiedShareUrl, setCopiedShareUrl] = useState(false)
+ const [exportingChartImage, setExportingChartImage] = useState(false)
+ const [includeAnalyticsInExport, setIncludeAnalyticsInExport] =
+ useState(false)
+ const [chartImageExportResult, setChartImageExportResult] =
+ useState(null)
+ const lastLoadedRef = useRef(null)
+ const copyResetRef = useRef | null>(null)
+ const chartShareResetRef = useRef | null>(null)
+ const captureSceneRef = useRef<(() => Promise) | null>(null)
+
+ const contributions = useMemo(() => profile?.data ?? [], [profile?.data])
+
+ const handleCaptureReady = useCallback(
+ (capture: () => Promise) => {
+ captureSceneRef.current = capture
+ },
+ []
+ )
+
+ const loadProfile = useCallback(async (rawInput: string) => {
+ const username = parseLeetCodeUsername(rawInput)
+ if (!username) {
+ setProfile(null)
+ setError("Enter a valid LeetCode username or profile link.")
+ return
+ }
+
+ setError(null)
+ setLoading(true)
+ setProfile(null)
+
+ try {
+ const response = await fetch(
+ `/api/leetcode?user=${encodeURIComponent(username)}`
+ )
+ const payload = await response.json()
+
+ if (!response.ok) {
+ throw new Error(payload.error ?? "Failed to load contributions.")
+ }
+
+ setProfile(payload as LeetCodeResult)
+ setInput(username)
+ lastLoadedRef.current = username
+ } catch (fetchError) {
+ setProfile(null)
+ setError(
+ fetchError instanceof Error
+ ? fetchError.message
+ : "Failed to load contributions."
+ )
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (initialUsername) {
+ setInput(initialUsername)
+ return
+ }
+
+ if (queryUser) {
+ setInput(queryUser)
+ }
+ }, [initialUsername, queryUser])
+
+ useEffect(() => {
+ if (!initialUsername) return
+
+ if (
+ lastLoadedRef.current?.toLowerCase() === initialUsername.toLowerCase()
+ ) {
+ return
+ }
+
+ void loadProfile(initialUsername)
+ }, [initialUsername, loadProfile])
+
+ useEffect(() => {
+ if (initialUsername) return
+ if (!queryUser) return
+
+ const username = parseLeetCodeUsername(queryUser)
+ if (!username) return
+
+ router.replace(`/leetcode/${username}`)
+ }, [initialUsername, queryUser, router])
+
+ useEffect(() => {
+ setCopiedShareUrl(false)
+ setChartImageExportResult(null)
+ captureSceneRef.current = null
+ if (copyResetRef.current) {
+ clearTimeout(copyResetRef.current)
+ copyResetRef.current = null
+ }
+ if (chartShareResetRef.current) {
+ clearTimeout(chartShareResetRef.current)
+ chartShareResetRef.current = null
+ }
+ }, [profile?.username])
+
+ useEffect(() => {
+ return () => {
+ if (copyResetRef.current) {
+ clearTimeout(copyResetRef.current)
+ }
+ if (chartShareResetRef.current) {
+ clearTimeout(chartShareResetRef.current)
+ }
+ }
+ }, [])
+
+ async function handleExportChartImage() {
+ if (!profile) return
+
+ const capture = captureSceneRef.current
+ if (!capture) {
+ setError("The chart is still loading. Try again in a moment.")
+ return
+ }
+
+ setExportingChartImage(true)
+ setChartImageExportResult(null)
+
+ try {
+ const chartBlob = await capture()
+ if (!chartBlob) {
+ throw new Error("Failed to capture chart image.")
+ }
+
+ const blob = await buildChartExportImage({
+ chartBlob,
+ profile,
+ contributions,
+ includeAnalytics: includeAnalyticsInExport,
+ })
+
+ const result = await exportChartImage(blob, profile.username)
+ setChartImageExportResult(result)
+ if (chartShareResetRef.current) clearTimeout(chartShareResetRef.current)
+ chartShareResetRef.current = setTimeout(
+ () => setChartImageExportResult(null),
+ 2000
+ )
+ } catch {
+ setError("Could not save the chart image. Please try again.")
+ } finally {
+ setExportingChartImage(false)
+ }
+ }
+
+ async function handleShareProfile() {
+ if (!profile) return
+
+ const shareUrl = `${window.location.origin}/leetcode/${profile.username}`
+
+ try {
+ await navigator.clipboard.writeText(shareUrl)
+ setCopiedShareUrl(true)
+ if (copyResetRef.current) clearTimeout(copyResetRef.current)
+ copyResetRef.current = setTimeout(() => setCopiedShareUrl(false), 2000)
+ } catch {
+ setError("Could not copy the share link. Please copy it manually.")
+ }
+ }
+
+ async function handleSubmit(
+ event: FormEvent,
+ closeMenu?: () => void
+ ) {
+ event.preventDefault()
+
+ const username = parseLeetCodeUsername(input.trim())
+ if (!username) {
+ setError("Enter a valid LeetCode username or profile link.")
+ return
+ }
+
+ setError(null)
+ void setQueryUser(null)
+
+ const targetPath = `/leetcode/${username}`
+ if (pathname !== targetPath) {
+ router.push(targetPath)
+ }
+
+ await loadProfile(username)
+ closeMenu?.()
+ }
+
+ return (
+
+
+ {profile ? (
+
+ ) : loading ? (
+
+
+
+ Loading @{parseLeetCodeUsername(input.trim()) ?? input.trim()}
+ 's contribution terrain...
+
+
+ ) : (
+
+
+ Enter a LeetCode profile to render their contribution terrain.
+
+
+ Try "theorcdev" or share a link like /leetcode/theorcdev
+
+
+ )}
+
+
+
+ {({ closeMenu }) => (
+ <>
+ void handleSubmit(event, closeMenu)}
+ />
+
+ {profile ? (
+
+ ) : null}
+
+ {profile ? (
+
+ Drag to rotate - Scroll to zoom - Height is 1 units per
+ contribution
+
+ ) : null}
+
+ {profile ? (
+
+
+
+
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ )
+}
diff --git a/components/leetcode-search-panel.tsx b/components/leetcode-search-panel.tsx
new file mode 100644
index 0000000..48dcc51
--- /dev/null
+++ b/components/leetcode-search-panel.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { Loader2 } from "lucide-react"
+import { FormEvent } from "react"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+type LeetCodeSearchPanelProps = {
+ input: string
+ loading: boolean
+ error: string | null
+ onInputChange: (value: string) => void
+ onSubmit: (event: FormEvent) => void
+}
+
+export function LeetCodeSearchPanel({
+ input,
+ loading,
+ error,
+ onInputChange,
+ onSubmit,
+}: LeetCodeSearchPanelProps) {
+ return (
+
+
+
+ Isometric LeetCode Contributions
+
+
+ Paste a LeetCode username or profile link. Shareable URLs look like
+ /leetcode/theorcdev.
+
+
+
+
+
+ {error ?
{error}
: null}
+
+ )
+}
diff --git a/lib/leetcode.ts b/lib/leetcode.ts
new file mode 100644
index 0000000..dae2b5f
--- /dev/null
+++ b/lib/leetcode.ts
@@ -0,0 +1,225 @@
+import type { ContributionDay } from "@/lib/contribution-data"
+import { GRAPH_CONFIG } from "@/lib/contribution-data"
+
+const USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]{0,23})$/
+
+type LeetCodeCalendar = {
+ submissionCalendar: string
+}
+
+type LeetCodeProfile = {
+ realName: string | null
+ userAvatar: string
+}
+
+type LeetCodeMatchedUser = {
+ username: string
+ profile: LeetCodeProfile
+ currentYearCalendar: LeetCodeCalendar | null
+ previousYearCalendar: LeetCodeCalendar | null
+}
+
+type LeetCodeGraphQLResponse = {
+ data?: {
+ matchedUser?: LeetCodeMatchedUser | null
+ }
+ errors?: { message: string }[]
+}
+
+export type LeetCodeResult = {
+ username: string
+ name: string | null
+ avatarUrl: string
+ totalContributions: number
+ data: ContributionDay[]
+}
+
+export function parseLeetCodeUsername(input: string): string | null {
+ const trimmed = input.trim()
+ if (!trimmed) return null
+
+ if (USERNAME_RE.test(trimmed)) return trimmed
+
+ try {
+ const withProtocol = trimmed.startsWith("http")
+ ? trimmed
+ : `https://${trimmed}`
+ const url = new URL(withProtocol)
+
+ if (url.hostname !== "leetcode.com" && url.hostname !== "www.leetcode.com") {
+ return null
+ }
+
+ const segments = url.pathname.split("/").filter(Boolean)
+ const [first, second] = segments
+
+ const reserved = new Set([
+ "problems",
+ "contest",
+ "discuss",
+ "explore",
+ "study-plan",
+ "u",
+ "profile",
+ "circle",
+ ])
+
+ let candidate = first
+ if (first === "u" && second) {
+ candidate = second
+ } else if (first && reserved.has(first.toLowerCase()) && first !== "u") {
+ return null
+ }
+
+ if (!candidate) return null
+ return USERNAME_RE.test(candidate) ? candidate : null
+ } catch {
+ return null
+ }
+}
+
+function startOfWeek(date: Date): Date {
+ const normalized = new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
+ )
+ normalized.setUTCDate(normalized.getUTCDate() - normalized.getUTCDay())
+ return normalized
+}
+
+function parseSubmissionCalendar(raw: string | undefined): Map {
+ const counts = new Map()
+ if (!raw) return counts
+
+ try {
+ const parsed = JSON.parse(raw) as Record
+ for (const [timestamp, count] of Object.entries(parsed)) {
+ const date = new Date(Number.parseInt(timestamp, 10) * 1000)
+ const dateKey = date.toISOString().split("T")[0]
+ counts.set(dateKey, (counts.get(dateKey) ?? 0) + count)
+ }
+ } catch {
+ // Ignore malformed calendar payloads; the grid will simply show zeros.
+ }
+
+ return counts
+}
+
+function buildContributionGrid(counts: Map): ContributionDay[] {
+ const { weeks, days } = GRAPH_CONFIG
+ const today = new Date()
+ const todayUTC = new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())
+ )
+ const todayKey = todayUTC.toISOString().split("T")[0]
+
+ const currentWeekStart = startOfWeek(todayUTC)
+ const gridStart = new Date(currentWeekStart)
+ gridStart.setUTCDate(gridStart.getUTCDate() - (weeks - 1) * 7)
+
+ const contributions: ContributionDay[] = []
+
+ for (let week = 0; week < weeks; week++) {
+ for (let day = 0; day < days; day++) {
+ const date = new Date(gridStart)
+ date.setUTCDate(date.getUTCDate() + week * 7 + day)
+ const dateKey = date.toISOString().split("T")[0]
+
+ // Skip dates beyond today so the grid never pads in future zero-count
+ // days, which would otherwise reset streak calculations to 0.
+ if (dateKey > todayKey) continue
+
+ contributions.push({
+ date: dateKey,
+ count: counts.get(dateKey) ?? 0,
+ week,
+ day,
+ })
+ }
+ }
+
+ return contributions
+}
+
+async function fetchLeetCodeGraphQL(
+ username: string
+): Promise {
+ const currentYear = new Date().getUTCFullYear()
+ const previousYear = currentYear - 1
+
+ const query = `
+ query userProfileCalendar($username: String!, $currentYear: Int!, $previousYear: Int!) {
+ matchedUser(username: $username) {
+ username
+ profile {
+ realName
+ userAvatar
+ }
+ currentYearCalendar: userCalendar(year: $currentYear) {
+ submissionCalendar
+ }
+ previousYearCalendar: userCalendar(year: $previousYear) {
+ submissionCalendar
+ }
+ }
+ }
+ `
+
+ const response = await fetch("https://leetcode.com/graphql", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ Referer: "https://leetcode.com",
+ },
+ body: JSON.stringify({
+ query,
+ variables: { username, currentYear, previousYear },
+ }),
+ next: { revalidate: 3600 },
+ })
+
+ if (!response.ok) {
+ throw new Error("Failed to reach LeetCode's API.")
+ }
+
+ const payload = (await response.json()) as LeetCodeGraphQLResponse
+
+ if (payload.errors?.length) {
+ throw new Error(payload.errors[0]?.message ?? "LeetCode API error")
+ }
+
+ return payload.data?.matchedUser ?? null
+}
+
+export async function fetchLeetCodeContributions(
+ username: string
+): Promise {
+ const user = await fetchLeetCodeGraphQL(username)
+
+ if (!user) {
+ throw new Error(`User "${username}" was not found on LeetCode.`)
+ }
+
+ const counts = new Map()
+ for (const [key, value] of parseSubmissionCalendar(
+ user.previousYearCalendar?.submissionCalendar
+ )) {
+ counts.set(key, value)
+ }
+ for (const [key, value] of parseSubmissionCalendar(
+ user.currentYearCalendar?.submissionCalendar
+ )) {
+ counts.set(key, value)
+ }
+
+ const data = buildContributionGrid(counts)
+ const totalContributions = data.reduce((sum, day) => sum + day.count, 0)
+
+ return {
+ username: user.username,
+ name: user.profile?.realName?.trim() ? user.profile.realName : null,
+ avatarUrl: user.profile?.userAvatar ?? "",
+ totalContributions,
+ data,
+ }
+}