From 000ce61146564b3493806aba9f3f2bc977256cc5 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Thu, 9 Jul 2026 02:16:40 +0530 Subject: [PATCH 1/2] feat: add leetcode graph support --- app/api/leetcode/route.ts | 26 ++ app/leetcode/[username]/page.tsx | 24 ++ app/leetcode/page.tsx | 9 + app/page.tsx | 9 + .../leetcode-graph/leetcode-graph-loader.tsx | 27 ++ components/leetcode-graph/leetcode-graph.tsx | 377 ++++++++++++++++++ components/leetcode-search-panel.tsx | 65 +++ lib/leetcode.ts | 225 +++++++++++ 8 files changed, 762 insertions(+) create mode 100644 app/api/leetcode/route.ts create mode 100644 app/leetcode/[username]/page.tsx create mode 100644 app/leetcode/page.tsx create mode 100644 components/leetcode-graph/leetcode-graph-loader.tsx create mode 100644 components/leetcode-graph/leetcode-graph.tsx create mode 100644 components/leetcode-search-panel.tsx create mode 100644 lib/leetcode.ts 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..ae3f16e --- /dev/null +++ b/components/leetcode-graph/leetcode-graph.tsx @@ -0,0 +1,377 @@ +"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. +

+
+ +
+ onInputChange(event.target.value)} + disabled={loading} + aria-invalid={!!error} + className="h-9 rounded-none border-emerald-100/20 bg-transparent text-white placeholder:text-emerald-100/40 placeholder:italic" + /> + +
+ + {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, + } +} From 5af232d690c53bee662624c9f2dda3427c821bc9 Mon Sep 17 00:00:00 2001 From: Saman Pandey Date: Thu, 9 Jul 2026 15:15:59 +0530 Subject: [PATCH 2/2] fix: fixed build-time errors --- components/leetcode-graph/leetcode-graph.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/components/leetcode-graph/leetcode-graph.tsx b/components/leetcode-graph/leetcode-graph.tsx index ae3f16e..12d88b9 100644 --- a/components/leetcode-graph/leetcode-graph.tsx +++ b/components/leetcode-graph/leetcode-graph.tsx @@ -257,20 +257,20 @@ export function LeetCodeGraph({ initialUsername }: LeetCodeGraphProps) { key={profile.username} data={contributions} onCaptureReady={handleCaptureReady} - heightUnit={1} /> ) : loading ? (

- Loading @ - {parseLeetCodeUsername(input.trim()) ?? input.trim()}'s - contribution terrain... + Loading @{parseLeetCodeUsername(input.trim()) ?? input.trim()} + 's contribution terrain...

) : (
-

Enter a LeetCode profile to render their contribution terrain.

+

+ Enter a LeetCode profile to render their contribution terrain. +

Try "theorcdev" or share a link like /leetcode/theorcdev

@@ -298,7 +298,8 @@ export function LeetCodeGraph({ initialUsername }: LeetCodeGraphProps) { {profile ? (

- Drag to rotate - Scroll to zoom - Height is 1 units per contribution + Drag to rotate - Scroll to zoom - Height is 1 units per + contribution

) : null}