Skip to content

Commit dc9de8e

Browse files
feat: implement interactive hover and tap tooltips on Customize page monolith preview
1 parent 6ede310 commit dc9de8e

6 files changed

Lines changed: 288 additions & 21 deletions

File tree

app/customize/page.tsx

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export default function CustomizePage(): ReactElement {
4646
const [copied, setCopied] = useState(false);
4747
const [copyStatusMessage, setCopyStatusMessage] = useState('');
4848
const copyResetTimeoutRef = useRef<number | null>(null);
49+
const [svgContent, setSvgContent] = useState<string>('');
50+
const [svgState, setSvgState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
51+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
4952
const trimmedUsername = username.trim();
5053
const hasUsername = trimmedUsername.length > 0;
5154
const isAutoTheme = theme === 'auto';
@@ -156,6 +159,55 @@ export default function CustomizePage(): ReactElement {
156159

157160
const queryString = buildQueryParams();
158161
const previewSrc = `/api/streak?${queryString}`;
162+
163+
useEffect(() => {
164+
// eslint-disable-next-line react-hooks/set-state-in-effect
165+
setErrorMessage(null);
166+
if (!hasUsername) {
167+
setSvgContent('');
168+
setSvgState('idle');
169+
return;
170+
}
171+
172+
setSvgState('loading');
173+
const controller = new AbortController();
174+
175+
fetch(previewSrc, { signal: controller.signal })
176+
.then(async (res) => {
177+
const text = await res.text();
178+
if (!res.ok) {
179+
setSvgContent('');
180+
setSvgState('error');
181+
if (res.status === 404 || res.status === 400) {
182+
setErrorMessage('GitHub user not found');
183+
} else if (res.status === 429) {
184+
setErrorMessage('Rate limit exceeded. Please try again later.');
185+
} else {
186+
setErrorMessage('Failed to load badge');
187+
}
188+
return;
189+
}
190+
return text;
191+
})
192+
.then((text) => {
193+
if (!text) return;
194+
// Basic SVG sanitization to prevent XSS (strip scripts and inline event handlers)
195+
const sanitized = text
196+
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
197+
.replace(/on\w+\s*=\s*("[^"]*"|'[^']*')/gi, '');
198+
setSvgContent(sanitized);
199+
setSvgState('loaded');
200+
setErrorMessage(null);
201+
})
202+
.catch((err) => {
203+
if (err.name === 'AbortError') return;
204+
setSvgState('error');
205+
setErrorMessage('Failed to load badge');
206+
});
207+
208+
return () => controller.abort();
209+
}, [previewSrc, hasUsername]);
210+
159211
const exportSnippet = getExportSnippet(exportFormat, queryString);
160212

161213
const fallbackCopyToClipboard = (text: string): boolean => {
@@ -371,17 +423,62 @@ export default function CustomizePage(): ReactElement {
371423
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-emerald-500/3 to-transparent animate-[pulse_3s_ease-in-out_infinite] pointer-events-none" />
372424

373425
{hasUsername ? (
374-
<>
375-
{/* eslint-disable-next-line @next/next/no-img-element */}
376-
<img
377-
key={previewSrc}
378-
src={previewSrc}
379-
alt="CommitPulse live preview"
380-
width={600}
381-
height={420}
382-
className="max-w-full h-auto drop-shadow-[0_20px_60px_rgba(0,0,0,0.6)] transition-opacity duration-300"
383-
/>
384-
</>
426+
<div className="w-full flex items-center justify-center">
427+
{svgState === 'loading' && (
428+
<div className="h-[240px] w-full max-w-[600px] rounded-2xl bg-black/5 dark:bg-white/5 animate-pulse flex items-center justify-center text-sm text-gray-500 dark:text-white/40">
429+
Loading preview...
430+
</div>
431+
)}
432+
{svgState === 'error' && errorMessage === 'GitHub user not found' && (
433+
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
434+
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-red-500/20 bg-red-500/10 shadow-inner">
435+
<svg
436+
xmlns="http://www.w3.org/2000/svg"
437+
className="h-8 w-8 text-red-500"
438+
viewBox="0 0 24 24"
439+
fill="none"
440+
stroke="currentColor"
441+
strokeWidth="2.5"
442+
strokeLinecap="round"
443+
strokeLinejoin="round"
444+
>
445+
<line x1="18" y1="6" x2="6" y2="18"></line>
446+
<line x1="6" y1="6" x2="18" y2="18"></line>
447+
</svg>
448+
</div>
449+
<div>
450+
<p className="text-lg font-bold text-gray-900 dark:text-white tracking-tight">
451+
GitHub user not found
452+
</p>
453+
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
454+
Please check the username and try again.
455+
</p>
456+
</div>
457+
</div>
458+
)}
459+
{svgState === 'error' && errorMessage !== 'GitHub user not found' && (
460+
<div className="flex flex-col items-center justify-center gap-2 text-center py-8">
461+
<p className="text-sm font-semibold text-red-500 dark:text-red-400">
462+
Failed to load badge
463+
</p>
464+
<p className="text-xs text-gray-500 dark:text-white/45">
465+
The API may be unavailable. Please try again.
466+
</p>
467+
</div>
468+
)}
469+
{svgState === 'loaded' && svgContent && (
470+
<motion.div
471+
initial={{ opacity: 0, scale: 0.95 }}
472+
animate={{ opacity: 1, scale: 1 }}
473+
transition={{ duration: 0.5, ease: 'easeOut' }}
474+
className="cp-svg-container w-full max-w-[600px] drop-shadow-[0_30px_60px_rgba(0,0,0,0.15)] dark:drop-shadow-[0_30px_60px_rgba(0,0,0,0.5)] [&>svg]:w-full [&>svg]:h-auto"
475+
dangerouslySetInnerHTML={{ __html: svgContent }}
476+
/>
477+
)}
478+
{svgState === 'loaded' && !svgContent && errorMessage && (
479+
<p className="text-red-400 text-sm text-center">{errorMessage}</p>
480+
)}
481+
</div>
385482
) : (
386483
<div className="relative z-10 flex w-full max-w-xl flex-col items-center justify-center rounded-[1.25rem] border border-dashed border-black/10 bg-gray-100/80 backdrop-blur-md dark:border-white/10 dark:bg-white/[0.03] px-6 py-12 text-center">
387484
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-black/10 bg-gray-100/80 dark:border-white/10 dark:bg-white/[0.04] text-gray-500 dark:text-emerald-300/70">

components/InteractiveViewer.tsx

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
'use client';
22

3-
import React, { useState, useRef, ReactNode, useMemo, type ReactElement } from 'react';
3+
import React, { useState, useRef, ReactNode, useMemo, useEffect, type ReactElement } from 'react';
4+
import { createPortal } from 'react-dom';
5+
import { AnimatePresence } from 'framer-motion';
6+
import VisualizationTooltip from './dashboard/VisualizationTooltip';
47

58
// ── Parallax particle configuration ──────────────────────────────────────────
69
// Particles are generated deterministically so SSR and client renders match,
@@ -45,6 +48,35 @@ function buildParticles(): ParallaxParticle[] {
4548
// container edge. Shallower particles shift proportionally less.
4649
const PARALLAX_STRENGTH = 80;
4750

51+
interface ActiveTooltipState {
52+
date: string;
53+
count: number;
54+
metric: string;
55+
x: number;
56+
y: number;
57+
}
58+
59+
const formatDate = (dateStr: string): string => {
60+
if (!dateStr) return '';
61+
const parts = dateStr.split('-');
62+
if (parts.length !== 3) return dateStr;
63+
const year = parseInt(parts[0], 10);
64+
const month = parseInt(parts[1], 10);
65+
const day = parseInt(parts[2], 10);
66+
if (isNaN(year) || isNaN(month) || isNaN(day)) return dateStr;
67+
try {
68+
const date = new Date(year, month - 1, day);
69+
const formatted = date.toLocaleDateString('en-US', {
70+
month: 'short',
71+
day: 'numeric',
72+
year: 'numeric',
73+
});
74+
return formatted === 'Invalid Date' ? dateStr : formatted;
75+
} catch {
76+
return dateStr;
77+
}
78+
};
79+
4880
interface InteractiveViewerProps {
4981
children: ReactNode;
5082
className?: string;
@@ -66,6 +98,14 @@ export default function InteractiveViewer({
6698
const isDragging = useRef(false);
6799
const [isDraggingState, setIsDraggingState] = useState(false);
68100
const lastMousePos = useRef({ x: 0, y: 0 });
101+
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
102+
const activeTooltipRef = useRef<ActiveTooltipState | null>(null);
103+
const startPointerPos = useRef({ x: 0, y: 0 });
104+
const [mounted, setMounted] = useState(false);
105+
useEffect(() => {
106+
// eslint-disable-next-line react-hooks/set-state-in-effect
107+
setMounted(true);
108+
}, []);
69109

70110
// Stable particle list — generated once on mount, never re-shuffled.
71111
const particles = useMemo((): ParallaxParticle[] => buildParticles(), []);
@@ -131,6 +171,7 @@ export default function InteractiveViewer({
131171
isDragging.current = true;
132172
setIsDraggingState(true);
133173
lastMousePos.current = { x: e.clientX, y: e.clientY };
174+
startPointerPos.current = { x: e.clientX, y: e.clientY };
134175
e.currentTarget.setPointerCapture(e.pointerId);
135176
};
136177

@@ -145,17 +186,80 @@ export default function InteractiveViewer({
145186
}
146187

147188
// Only apply pan logic when actively dragging
148-
if (!isDragging.current) return;
149-
const dx = e.clientX - lastMousePos.current.x;
150-
const dy = e.clientY - lastMousePos.current.y;
151-
setPan((p) => ({ x: p.x + dx, y: p.y + dy }));
152-
lastMousePos.current = { x: e.clientX, y: e.clientY };
189+
if (isDragging.current) {
190+
const dx = e.clientX - lastMousePos.current.x;
191+
const dy = e.clientY - lastMousePos.current.y;
192+
setPan((p) => ({ x: p.x + dx, y: p.y + dy }));
193+
lastMousePos.current = { x: e.clientX, y: e.clientY };
194+
// Hide tooltip during active drag/pan
195+
activeTooltipRef.current = null;
196+
setActiveTooltip(null);
197+
return;
198+
}
199+
200+
// Detect if we are hovering over an interactive tower
201+
const targetElement = e.target as HTMLElement;
202+
const tower = targetElement.closest('.interactive-tower');
203+
if (tower) {
204+
const date = tower.getAttribute('data-date');
205+
const countStr = tower.getAttribute('data-count');
206+
const metric = tower.getAttribute('data-metric');
207+
if (date && countStr && metric) {
208+
if (!activeTooltipRef.current || activeTooltipRef.current.date !== date) {
209+
const count = parseInt(countStr, 10);
210+
const towerRect = tower.getBoundingClientRect();
211+
const newTooltip = {
212+
date,
213+
count,
214+
metric,
215+
x: towerRect.left + towerRect.width / 2,
216+
y: towerRect.top,
217+
};
218+
activeTooltipRef.current = newTooltip;
219+
setActiveTooltip(newTooltip);
220+
}
221+
}
222+
} else {
223+
if (activeTooltipRef.current) {
224+
activeTooltipRef.current = null;
225+
setActiveTooltip(null);
226+
}
227+
}
153228
};
154229

155230
const handlePointerUp = (e: React.PointerEvent): void => {
156231
isDragging.current = false;
157232
setIsDraggingState(false);
158233
e.currentTarget.releasePointerCapture(e.pointerId);
234+
235+
// If it was a tap (moved very little), show/toggle the tooltip!
236+
const dx = Math.abs(e.clientX - startPointerPos.current.x);
237+
const dy = Math.abs(e.clientY - startPointerPos.current.y);
238+
if (dx < 5 && dy < 5) {
239+
const targetElement = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement;
240+
const tower = targetElement?.closest('.interactive-tower');
241+
if (tower) {
242+
const date = tower.getAttribute('data-date');
243+
const countStr = tower.getAttribute('data-count');
244+
const metric = tower.getAttribute('data-metric');
245+
if (date && countStr && metric) {
246+
const count = parseInt(countStr, 10);
247+
const towerRect = tower.getBoundingClientRect();
248+
const newTooltip = {
249+
date,
250+
count,
251+
metric,
252+
x: towerRect.left + towerRect.width / 2,
253+
y: towerRect.top,
254+
};
255+
activeTooltipRef.current = newTooltip;
256+
setActiveTooltip(newTooltip);
257+
return;
258+
}
259+
}
260+
}
261+
activeTooltipRef.current = null;
262+
setActiveTooltip(null);
159263
};
160264

161265
const handlePointerEnter = (): void => setIsHovering(true);
@@ -164,6 +268,8 @@ export default function InteractiveViewer({
164268
setIsHovering(false);
165269
// Reset cursor position to center so the glow fades out gracefully from center
166270
setMousePos({ x: 0.5, y: 0.5 });
271+
activeTooltipRef.current = null;
272+
setActiveTooltip(null);
167273
};
168274

169275
const handleWheel = (e: React.WheelEvent): void => {
@@ -278,6 +384,52 @@ export default function InteractiveViewer({
278384
>
279385
{children}
280386
</div>
387+
388+
{mounted &&
389+
createPortal(
390+
<AnimatePresence>
391+
{activeTooltip && (
392+
<VisualizationTooltip
393+
title={formatDate(activeTooltip.date)}
394+
x={activeTooltip.x}
395+
y={activeTooltip.y}
396+
>
397+
<div className="flex flex-col gap-1.5 min-w-[140px] p-0.5">
398+
<div className="text-[11px] font-semibold text-gray-900 dark:text-zinc-100 flex justify-between items-center">
399+
<span>Contributions</span>
400+
<span className="text-emerald-500 dark:text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.5 rounded text-[10px] min-w-[1.5rem] text-center">
401+
{activeTooltip.count}
402+
</span>
403+
</div>
404+
<div className="h-px bg-black/5 dark:bg-white/5 w-full" />
405+
<div className="flex items-center gap-1.5">
406+
<span
407+
className={`inline-block w-1.5 h-1.5 rounded-full ${
408+
activeTooltip.metric === 'Peak day'
409+
? 'bg-emerald-500 shadow-[0_0_6px_#10b981]'
410+
: activeTooltip.metric === 'Active day'
411+
? 'bg-cyan-500 shadow-[0_0_6px_#06b6d4]'
412+
: 'bg-zinc-400 dark:bg-zinc-500'
413+
}`}
414+
/>
415+
<span
416+
className={`text-[9px] font-bold uppercase tracking-wider ${
417+
activeTooltip.metric === 'Peak day'
418+
? 'text-emerald-500 dark:text-emerald-400'
419+
: activeTooltip.metric === 'Active day'
420+
? 'text-cyan-500 dark:text-cyan-400'
421+
: 'text-zinc-500 dark:text-zinc-400'
422+
}`}
423+
>
424+
{activeTooltip.metric}
425+
</span>
426+
</div>
427+
</div>
428+
</VisualizationTooltip>
429+
)}
430+
</AnimatePresence>,
431+
document.body
432+
)}
281433
</div>
282434
);
283435
}

lib/export3d.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('generateMonolithSTL', () => {
1515
isToday: false,
1616
isTodayWithCommits: false,
1717
tooltip: '',
18+
date: '',
1819
contributionCount: 5,
1920
faceOpacity: { left: 1, right: 1, top: 1 },
2021
strokeOpacity: 1,
@@ -32,6 +33,7 @@ describe('generateMonolithSTL', () => {
3233
isToday: false,
3334
isTodayWithCommits: false,
3435
tooltip: '',
36+
date: '',
3537
contributionCount: 0,
3638
faceOpacity: { left: 1, right: 1, top: 1 },
3739
strokeOpacity: 1,
@@ -63,6 +65,7 @@ it('generates structurally valid ASCII STL facets', () => {
6365
isToday: false,
6466
isTodayWithCommits: false,
6567
tooltip: '',
68+
date: '',
6669
contributionCount: 5,
6770
faceOpacity: { left: 1, right: 1, top: 1 },
6871
strokeOpacity: 1,
@@ -111,6 +114,7 @@ it('skips ghost towers (h=0) while still generating the base plate', () => {
111114
isToday: false,
112115
isTodayWithCommits: false,
113116
tooltip: '',
117+
date: '',
114118
contributionCount: 0,
115119
faceOpacity: { left: 1, right: 1, top: 1 },
116120
strokeOpacity: 1,

lib/svg/generator.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,7 @@ describe('shading', () => {
10791079
shadingCalendar
10801080
);
10811081
// The shaded SVG should still contain the tower paths
1082-
expect(svgShading).toContain('class="cp-tower"');
1082+
expect(svgShading).toContain('class="cp-tower interactive-tower"');
10831083
// For level 1 (mult=0.4), base top face opacity 0.7 becomes 0.28
10841084
// Check for that specific derived value to ensure shading actually multiplied it.
10851085
expect(svgShading).toContain('fill-opacity="0.28"');

0 commit comments

Comments
 (0)