From 153aa3386e633f9e1dffff466caf6e22c860b3f8 Mon Sep 17 00:00:00 2001 From: Subhash21022 Date: Sun, 21 Jun 2026 19:38:26 +0530 Subject: [PATCH] feat: add Time-Lapse playback for 3D Badge Preview --- .../sections/CommitPulseSection.tsx | 135 ++++++++++++++---- components/dashboard/ActivityLandscape.tsx | 42 +++++- components/dashboard/ContributionCity3D.tsx | 112 +++++++++++++-- 3 files changed, 250 insertions(+), 39 deletions(-) diff --git a/app/generator/components/sections/CommitPulseSection.tsx b/app/generator/components/sections/CommitPulseSection.tsx index 07b96f85d..d6ae937d3 100644 --- a/app/generator/components/sections/CommitPulseSection.tsx +++ b/app/generator/components/sections/CommitPulseSection.tsx @@ -1,8 +1,11 @@ 'use client'; import Image from 'next/image'; -import { useState, useEffect } from 'react'; -import { Loader2, Search, X, ExternalLink } from 'lucide-react'; +import { useState, useEffect, lazy, Suspense } from 'react'; +import { Loader2, Search, X, ExternalLink, PlaySquare } from 'lucide-react'; +import type { ActivityData } from '@/types/dashboard'; + +const ContributionCity3D = lazy(() => import('@/components/dashboard/ContributionCity3D')); import { SectionCard, FieldLabel } from '../SectionCard'; import { validateGitHubUsername } from '@/lib/validations'; @@ -89,6 +92,10 @@ export function CommitPulseSection({ const [badgeLoaded, setBadgeLoaded] = useState(false); const [badgeError, setBadgeError] = useState(false); + const [timeLapseMode, setTimeLapseMode] = useState(false); + const [fullActivityData, setFullActivityData] = useState(null); + const [loadingFullData, setLoadingFullData] = useState(false); + useEffect(() => { if (!debouncedUsername) { // eslint-disable-next-line react-hooks/set-state-in-effect @@ -146,6 +153,33 @@ export function CommitPulseSection({ }; }, [debouncedUsername]); + useEffect(() => { + if (timeLapseMode && !fullActivityData && debouncedUsername && userDetails && !fetchError) { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoadingFullData(true); + fetch(`/api/github?username=${encodeURIComponent(debouncedUsername)}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to fetch full data'); + return res.json(); + }) + .then((data) => { + if (!cancelled) { + setFullActivityData(data.activity); + setLoadingFullData(false); + } + }) + .catch(() => { + if (!cancelled) { + setLoadingFullData(false); + } + }); + return () => { + cancelled = true; + }; + } + }, [timeLapseMode, debouncedUsername, userDetails, fetchError, fullActivityData]); + const badgeUrl = userDetails && !fetchError ? buildBadgeUrl(debouncedUsername, safeAccent) : null; const accentIsValid = /^[0-9a-fA-F]{6}$/.test(safeAccent.replace(/^#/, '')); @@ -332,7 +366,21 @@ export function CommitPulseSection({ {badgeUrl && userDetails && (
- Live Preview +
+ Live Preview + {/* Time-Lapse Toggle */} + +
- {!badgeLoaded && !badgeError && ( -
- -
- )} - {badgeError && ( -

- Could not load badge preview. The streak data may still be generating — check - the full dashboard link above. -

+ {timeLapseMode ? ( + fullActivityData ? ( + } + > +
+ +
+
+ ) : loadingFullData ? ( +
+ + + Loading full activity data... + +
+ ) : ( +

+ Failed to load time-lapse data. +

+ ) + ) : ( + <> + {!badgeLoaded && !badgeError && ( +
+ +
+ )} + {badgeError && ( +

+ Could not load badge preview. The streak data may still be generating — + check the full dashboard link above. +

+ )} + {`CommitPulse { + setBadgeLoaded(true); + setBadgeError(false); + }} + onError={() => { + setBadgeError(true); + setBadgeLoaded(false); + }} + /> + )} - {`CommitPulse { - setBadgeLoaded(true); - setBadgeError(false); - }} - onError={() => { - setBadgeError(true); - setBadgeLoaded(false); - }} - />
{userDetails.stats && ( diff --git a/components/dashboard/ActivityLandscape.tsx b/components/dashboard/ActivityLandscape.tsx index 6353e0a73..653680d57 100644 --- a/components/dashboard/ActivityLandscape.tsx +++ b/components/dashboard/ActivityLandscape.tsx @@ -76,6 +76,7 @@ export default function ActivityLandscape({ data }: { data: ActivityData[] }) { const [mode, setMode] = useState<'commits' | 'loc'>('commits'); const [tooltip, setTooltip] = useState(null); const [is3D, setIs3D] = useState(false); + const [timeLapseMode, setTimeLapseMode] = useState(false); const { t } = useTranslation(); const theme3D = use3DTheme(); @@ -190,7 +191,10 @@ export default function ActivityLandscape({ data }: { data: ActivityData[] }) { {/* ── 3D City View toggle ── */} + + {/* ── Time Lapse View toggle ── */} + + {is3D && ( + setTimeLapseMode((v) => !v)} + aria-pressed={timeLapseMode} + title={timeLapseMode ? 'Turn off Time-Lapse' : 'Turn on Time-Lapse'} + className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-semibold transition-all duration-200 overflow-hidden whitespace-nowrap ${ + timeLapseMode + ? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-500 dark:border-emerald-400/30 dark:bg-emerald-500/10' + : 'border-black/10 bg-transparent text-gray-500 hover:border-black/20 hover:text-black dark:border-white/10 dark:hover:border-white/20 dark:hover:text-white' + }`} + > + + + + Time-Lapse + + )} +
@@ -233,7 +271,7 @@ export default function ActivityLandscape({ data }: { data: ActivityData[] }) { } > - + )} diff --git a/components/dashboard/ContributionCity3D.tsx b/components/dashboard/ContributionCity3D.tsx index a4a6e22df..29ac064bb 100644 --- a/components/dashboard/ContributionCity3D.tsx +++ b/components/dashboard/ContributionCity3D.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; +import { Play, Pause, RotateCcw } from 'lucide-react'; import type { ActivityData } from '@/types/dashboard'; // ─── Theme palette (mirrors lib/svg/themes.ts accent colours) ──────────────── @@ -53,6 +54,7 @@ export interface ContributionCity3DProps { theme?: string; /** Show the last N days of data (default: 98 = 14 weeks) */ days?: number; + timeLapseMode?: boolean; } interface TooltipState { @@ -66,6 +68,7 @@ export default function ContributionCity3D({ data, theme = 'dark', days = 98, + timeLapseMode = false, }: ContributionCity3DProps) { const canvasRef = useRef(null); const containerRef = useRef(null); @@ -74,6 +77,10 @@ export default function ContributionCity3D({ const [isDragging, setIsDragging] = useState(false); const [tooltip, setTooltip] = useState(null); + // Time-Lapse state + const [isPlaying, setIsPlaying] = useState(timeLapseMode); + const [playbackIndex, setPlaybackIndex] = useState(timeLapseMode ? 7 : days); + const cameraRef = useRef({ rotY: 0.45, // orbit angle (0 = looking from +Z) tiltX: 0.52, // vertical tilt (radians; ~30°) @@ -91,7 +98,9 @@ export default function ContributionCity3D({ const recent = data.slice(-days); const max = Math.max(...recent.map((d) => d.count), 1); - return recent.map((d, i) => ({ + const visibleData = timeLapseMode ? recent.slice(0, playbackIndex) : recent; + + return visibleData.map((d, i) => ({ col: Math.floor(i / 7), // week column row: i % 7, // day-of-week row height: d.count === 0 ? 0.04 : 0.1 + 0.9 * (d.count / max), @@ -99,7 +108,7 @@ export default function ContributionCity3D({ date: d.date, intensity: d.intensity, })); - }, [data, days]); + }, [data, days, timeLapseMode, playbackIndex]); // ── Draw ─────────────────────────────────────────────────────────────────── const draw = useCallback(() => { @@ -281,6 +290,40 @@ export default function ContributionCity3D({ draw(); }, [draw]); + // ── Time-Lapse Animation Loop ────────────────────────────────────────────── + useEffect(() => { + if (!timeLapseMode) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setPlaybackIndex(days); + + setIsPlaying(false); + return; + } + + if (!isPlaying) return; + + const maxIndex = Math.min(days, data.length); + let frame: number; + let lastTime = performance.now(); + + const tick = (time: number) => { + if (time - lastTime > 60) { + setPlaybackIndex((prev) => { + if (prev >= maxIndex) { + setIsPlaying(false); + return maxIndex; + } + return Math.min(prev + 7, maxIndex); + }); + lastTime = time; + } + frame = requestAnimationFrame(tick); + }; + + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [timeLapseMode, isPlaying, data.length, days]); + // ── Pointer events – orbit drag ──────────────────────────────────────────── const onPointerDown = (e: React.PointerEvent) => { (e.target as HTMLElement).setPointerCapture(e.pointerId); @@ -377,12 +420,65 @@ export default function ContributionCity3D({ )} {/* Controls hint */} -
- Drag to rotate · Scroll to zoom -
+ {!timeLapseMode && ( +
+ Drag to rotate · Scroll to zoom +
+ )} + + {/* Time-Lapse UI Overlay */} + {timeLapseMode && ( +
+ {/* Playback Controls */} +
+ + +
+
+ {playbackIndex > 0 && + (() => { + const recent = data.slice(-days); + const currentData = recent[Math.min(playbackIndex - 1, recent.length - 1)]; + if (!currentData) return '...'; + const dateObj = new Date(currentData.date); + return dateObj.toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + }); + })()} +
+
+
+ )}
); }