Skip to content

Commit 211cbb5

Browse files
committed
refactor: remove interactive 3D viewer and simplify SVG badge rendering and state management
1 parent 6a3d87e commit 211cbb5

5 files changed

Lines changed: 70 additions & 179 deletions

File tree

app/page.test.tsx

Lines changed: 38 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ vi.mock('framer-motion', () => ({
112112
{children}
113113
</a>
114114
),
115+
img: ({
116+
children,
117+
className,
118+
src,
119+
alt,
120+
onLoad,
121+
onError,
122+
initial,
123+
animate,
124+
exit,
125+
transition,
126+
...props
127+
}: any) => (
128+
<img className={className} src={src} alt={alt} onLoad={onLoad} onError={onError} {...props} />
129+
),
115130
},
116131
AnimatePresence: ({ children }: any) => <>{children}</>,
117132
}));
@@ -135,18 +150,6 @@ describe('LandingPage', () => {
135150
mockRecentSearches.clearSearches = vi.fn();
136151
mockRecentSearches.removeSearch = vi.fn();
137152

138-
// Mock fetch so the SVG preview useEffect resolves without a real network call.
139-
// Returns a minimal valid SVG so dangerouslySetInnerHTML has something to render.
140-
vi.stubGlobal(
141-
'fetch',
142-
vi.fn().mockResolvedValue({
143-
ok: true,
144-
status: 200,
145-
text: () =>
146-
Promise.resolve('<svg data-testid="badge-svg" xmlns="http://www.w3.org/2000/svg"></svg>'),
147-
})
148-
);
149-
150153
// Mock navigator.clipboard
151154
Object.assign(navigator, {
152155
clipboard: {
@@ -197,7 +200,7 @@ describe('LandingPage', () => {
197200
expect(screen.queryByTestId('badge-img')).toBeNull();
198201
});
199202

200-
it('updates the username when input changes and fetches the badge', async () => {
203+
it('updates the username when input changes and shows the badge img', async () => {
201204
render(<LandingPage />);
202205
const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement;
203206

@@ -206,17 +209,17 @@ describe('LandingPage', () => {
206209
});
207210
expect(input.value).toBe('octocat');
208211

209-
// The component fetches the badge SVG from the API with the correct URL
212+
// The badge img element should appear in the DOM with the correct URL
210213
await waitFor(() => {
211-
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
212-
expect.stringContaining('user=octocat'),
213-
expect.objectContaining({ signal: expect.any(AbortSignal) })
214-
);
214+
const img = screen.getByTestId('badge-img') as HTMLImageElement;
215+
expect(img).toBeDefined();
216+
expect(img.src).toContain('user=octocat');
215217
});
216218

217-
// After the fetch resolves the badge img element should be in the DOM
218-
await waitFor(() => {
219-
expect(screen.getByTestId('badge-img')).toBeDefined();
219+
// Simulate the browser successfully loading the badge image
220+
await act(async () => {
221+
const img = screen.getByTestId('badge-img') as HTMLImageElement;
222+
fireEvent.load(img);
220223
});
221224
});
222225

@@ -349,25 +352,19 @@ describe('LandingPage', () => {
349352
});
350353

351354
it('shows the friendly error UI instead of raw JSON when the API returns a 400', async () => {
352-
// Username with an underscore passes the UI 39-char limit but fails the API regex,
353-
// which returns JSON {error: 'Invalid parameters'} with status 400. Without the fix
354-
// that JSON string would be rendered via dangerouslySetInnerHTML.
355-
vi.stubGlobal(
356-
'fetch',
357-
vi.fn().mockResolvedValue({
358-
ok: false,
359-
status: 400,
360-
text: () => Promise.resolve(JSON.stringify({ error: 'Invalid parameters' })),
361-
})
362-
);
363-
364355
render(<LandingPage />);
365356
const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement;
366357

367358
await act(async () => {
368359
fireEvent.change(input, { target: { value: 'invalid_user' } });
369360
});
370361

362+
// Badge img renders; simulate the browser failing to load it (e.g. API returned 400)
363+
await waitFor(() => screen.getByTestId('badge-img'));
364+
await act(async () => {
365+
fireEvent.error(screen.getByTestId('badge-img'));
366+
});
367+
371368
await waitFor(() => {
372369
expect(screen.getByText('GitHub user not found')).toBeDefined();
373370
});
@@ -377,22 +374,19 @@ describe('LandingPage', () => {
377374
});
378375

379376
it('shows the friendly error UI for any non-ok API response (e.g. 429 rate limit)', async () => {
380-
vi.stubGlobal(
381-
'fetch',
382-
vi.fn().mockResolvedValue({
383-
ok: false,
384-
status: 429,
385-
text: () => Promise.resolve(JSON.stringify({ error: 'Too Many Requests' })),
386-
})
387-
);
388-
389377
render(<LandingPage />);
390378
const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement;
391379

392380
await act(async () => {
393381
fireEvent.change(input, { target: { value: 'octocat' } });
394382
});
395383

384+
// Simulate the browser failing to load the badge image
385+
await waitFor(() => screen.getByTestId('badge-img'));
386+
await act(async () => {
387+
fireEvent.error(screen.getByTestId('badge-img'));
388+
});
389+
396390
await waitFor(() => {
397391
expect(screen.getByText('GitHub user not found')).toBeDefined();
398392
});
@@ -401,21 +395,8 @@ describe('LandingPage', () => {
401395
});
402396

403397
it('renders a badge img (not inline SVG) so XSS via SVG content is structurally impossible', async () => {
404-
// The fetch mock returns an SVG with a <script> tag, but the new implementation
405-
// never injects SVG text into the DOM — it uses <img src=URL> which the browser
406-
// renders opaquely. No script tag should ever appear in the document.
407-
vi.stubGlobal(
408-
'fetch',
409-
vi.fn().mockResolvedValue({
410-
ok: true,
411-
status: 200,
412-
text: () =>
413-
Promise.resolve(
414-
'<svg data-testid="badge-svg"><script>alert("xss")</script><circle /></svg>'
415-
),
416-
})
417-
);
418-
398+
// The new implementation uses <img src=URL> which the browser renders opaquely.
399+
// No SVG text is ever injected into the DOM, so no <script> tag can exist.
419400
render(<LandingPage />);
420401

421402
const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement;

app/page.tsx

Lines changed: 28 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,12 @@ const Icons = {
7373
export default function LandingPage() {
7474
const [username, setUsername] = useState('');
7575
const [copied, setCopied] = useState(false);
76-
const [svgState, setSvgState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
77-
const [errorMessage, setErrorMessage] = useState<string | null>(null);
76+
// Track which username's badge result we have. Derived booleans auto-reset
77+
// when debouncedUsername changes — no useEffect needed.
78+
const [badgeResult, setBadgeResult] = useState<{
79+
username: string;
80+
status: 'loaded' | 'error';
81+
} | null>(null);
7882
const guideRef = useRef<HTMLDivElement>(null);
7983
const { searches, addSearch, clearSearches, removeSearch } = useRecentSearches();
8084
const [mounted, setMounted] = useState(false);
@@ -92,39 +96,10 @@ export default function LandingPage() {
9296
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://commitpulse.vercel.app';
9397
const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${trimmedUsername})`;
9498

95-
// Probe badge URL for errors; actual rendering uses a native <img> tag.
96-
useEffect(() => {
97-
if (!hasUsername) {
98-
// eslint-disable-next-line react-hooks/set-state-in-effect
99-
setSvgState('idle');
100-
return;
101-
}
102-
103-
setSvgState('loading');
104-
105-
const controller = new AbortController();
106-
107-
fetch(badgeUrl, { signal: controller.signal })
108-
.then(async (res) => {
109-
if (!res.ok) {
110-
setSvgState('error');
111-
if (res.status === 404 || res.status === 400 || res.status === 429) {
112-
setErrorMessage('GitHub user not found');
113-
} else {
114-
setErrorMessage('Failed to load badge');
115-
}
116-
return;
117-
}
118-
setSvgState('loaded');
119-
setErrorMessage(null);
120-
})
121-
.catch((err) => {
122-
if (err.name === 'AbortError') return;
123-
setSvgState('error');
124-
setErrorMessage('Failed to load badge');
125-
});
126-
return () => controller.abort();
127-
}, [badgeUrl, hasUsername]);
99+
// Derived — automatically false when debouncedUsername changes
100+
const badgeLoaded =
101+
badgeResult?.username === debouncedUsername && badgeResult?.status === 'loaded';
102+
const badgeError = badgeResult?.username === debouncedUsername && badgeResult?.status === 'error';
128103

129104
const copyToClipboard = () => {
130105
if (trimmedUsername.length === 0) return;
@@ -140,39 +115,6 @@ export default function LandingPage() {
140115
setTimeout(() => setCopied(false), 50000);
141116
};
142117

143-
const handleRotate3D = (dx: number, dy: number) => {
144-
const towersGroup = document.getElementById('cp-towers');
145-
if (!towersGroup) return;
146-
147-
// Remove any transition so dragging feels instant and responsive
148-
towersGroup.style.transition = 'none';
149-
150-
let currentX = 0;
151-
let currentY = 0;
152-
153-
const transformStr = towersGroup.style.transform;
154-
if (transformStr) {
155-
const matchX = transformStr.match(/rotateX\(([-0-9.]+)deg\)/);
156-
const matchY = transformStr.match(/rotateY\(([-0-9.]+)deg\)/);
157-
if (matchX) currentX = parseFloat(matchX[1]);
158-
if (matchY) currentY = parseFloat(matchY[1]);
159-
}
160-
161-
const newX = currentX - dy * 0.5;
162-
const newY = currentY + dx * 0.5;
163-
164-
towersGroup.style.transform = `translate(0px, 20px) rotateX(${newX}deg) rotateY(${newY}deg)`;
165-
};
166-
167-
const handleReset3D = () => {
168-
const towersGroup = document.getElementById('cp-towers');
169-
if (towersGroup) {
170-
// Apply a smooth transition for the reset
171-
towersGroup.style.transition = 'transform 1.2s cubic-bezier(0.16, 1, 0.3, 1)';
172-
towersGroup.style.transform = '';
173-
}
174-
};
175-
176118
return (
177119
<div className="min-h-screen overflow-x-hidden bg-transparent font-sans text-black dark:text-white selection:bg-black/20 dark:selection:bg-white/20">
178120
<div className="pointer-events-none fixed inset-0 overflow-hidden">
@@ -341,18 +283,13 @@ export default function LandingPage() {
341283

342284
<div className="group relative mt-10">
343285
<div className="absolute -inset-1 rounded-[2.5rem] bg-gradient-to-r from-emerald-500/20 to-cyan-500/20 opacity-50 blur-2xl transition duration-1000 group-hover:opacity-100" />
344-
<InteractiveViewer
345-
className="relative flex min-h-[480px] md:min-h-[520px] items-center justify-center overflow-visible rounded-3xl border border-black/5 bg-white/50 p-8 backdrop-blur-xl shadow-2xl dark:border-white/10 dark:bg-[#0a0a0a]/80"
346-
is3DMode={true}
347-
onRotate3D={handleRotate3D}
348-
onReset3D={handleReset3D}
349-
>
286+
<div className="relative flex min-h-[480px] md:min-h-[520px] items-center justify-center overflow-hidden rounded-3xl border border-black/5 bg-white/50 p-8 backdrop-blur-xl shadow-2xl dark:border-white/10 dark:bg-[#0a0a0a]/80">
350287
{hasUsername ? (
351-
<div className="w-full flex items-center justify-center">
352-
{svgState === 'loading' && (
288+
<div className="w-full flex flex-col items-center justify-center gap-4">
289+
{!badgeLoaded && !badgeError && (
353290
<div className="h-[240px] w-full max-w-[700px] rounded-2xl bg-black/5 dark:bg-white/5 animate-pulse" />
354291
)}
355-
{svgState === 'error' && errorMessage === 'GitHub user not found' && (
292+
{badgeError && (
356293
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
357294
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-red-500/20 bg-red-500/10 shadow-inner">
358295
<X size={32} className="text-red-500" />
@@ -367,32 +304,18 @@ export default function LandingPage() {
367304
</div>
368305
</div>
369306
)}
370-
{svgState === 'error' && errorMessage !== 'GitHub user not found' && (
371-
<div className="flex flex-col items-center justify-center gap-2 text-center py-8">
372-
<p className="text-sm font-semibold text-red-500 dark:text-red-400">
373-
Failed to load badge
374-
</p>
375-
<p className="text-xs text-gray-500 dark:text-white/55">
376-
The API may be unavailable. Please try again.
377-
</p>
378-
</div>
379-
)}
380-
{svgState === 'loaded' && (
381-
<motion.div
382-
initial={{ opacity: 0, scale: 0.95 }}
383-
animate={{ opacity: 1, scale: 1 }}
384-
transition={{ duration: 0.5, ease: 'easeOut' }}
385-
className="w-full max-w-[700px] drop-shadow-[0_30px_60px_rgba(0,0,0,0.15)] dark:drop-shadow-[0_30px_60px_rgba(0,0,0,0.5)]"
386-
>
387-
{/* eslint-disable-next-line @next/next/no-img-element */}
388-
<img
389-
data-testid="badge-img"
390-
src={badgeUrl}
391-
alt={`CommitPulse badge for ${debouncedUsername}`}
392-
className="w-full h-auto"
393-
/>
394-
</motion.div>
395-
)}
307+
<motion.img
308+
key={badgeUrl}
309+
data-testid="badge-img"
310+
src={badgeUrl}
311+
alt={`CommitPulse badge for ${debouncedUsername}`}
312+
initial={{ opacity: 0, scale: 0.95 }}
313+
animate={{ opacity: badgeLoaded ? 1 : 0, scale: badgeLoaded ? 1 : 0.95 }}
314+
transition={{ duration: 0.5, ease: 'easeOut' }}
315+
className="w-full max-w-[700px] h-auto drop-shadow-[0_30px_60px_rgba(0,0,0,0.15)] dark:drop-shadow-[0_30px_60px_rgba(0,0,0,0.5)]"
316+
onLoad={() => setBadgeResult({ username: debouncedUsername, status: 'loaded' })}
317+
onError={() => setBadgeResult({ username: debouncedUsername, status: 'error' })}
318+
/>
396319
</div>
397320
) : (
398321
<div className="flex w-full max-w-2xl flex-col items-center justify-center rounded-3xl border border-dashed border-black/10 bg-black/[0.02] px-6 py-16 text-center dark:border-white/10 dark:bg-white/[0.02]">
@@ -403,12 +326,11 @@ export default function LandingPage() {
403326
Ready to visualize your rhythm?
404327
</p>
405328
<p className="mt-3 max-w-md text-sm leading-relaxed text-gray-500 dark:text-white/65">
406-
Enter a GitHub username above to instantly generate your 3D contribution
407-
monolith preview.
329+
Enter a GitHub username above to instantly generate your streak badge.
408330
</p>
409331
</div>
410332
)}
411-
</InteractiveViewer>
333+
</div>
412334
</div>
413335
</section>
414336

commitpulse

Lines changed: 0 additions & 1 deletion
This file was deleted.

lib/svg/generator.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -532,11 +532,7 @@ export function generateSVG(
532532
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
533533

534534
return `
535-
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
536-
${renderHeader(safeUser, stats, sf, params, safeId)}
537-
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
538-
${renderHeader(safeUser, stats, sf, params, safeId)}
539-
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg)}
535+
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
540536
${renderHeader(safeUser, stats, sf, params, safeId)}
541537
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg, params.entrance || 'rise')}
542538
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" ${borderAttr} />
@@ -1845,14 +1841,11 @@ export function generateVersusSVG(
18451841
const safeId = `${safeUser1}_vs_${safeUser2}`.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
18461842

18471843
return `
1848-
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
1844+
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
18491845
<title id="cp-title-${safeId}">CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
18501846
<desc id="cp-desc-${safeId}">${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
1851-
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
1852-
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
1853-
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
18541847
${renderDefs(sf, params)}
1855-
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf, bg)}
1848+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf, bg, params.entrance || 'rise')}
18561849
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" />
18571850
18581851
<g transform="translate(0, 0)">
@@ -1987,12 +1980,9 @@ function generateAutoThemeVersusSVG(
19871980
const safeId = `${safeUser1}_vs_${safeUser2}`.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
19881981

19891982
return `
1990-
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
1983+
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
19911984
<title id="cp-title-${safeId}">CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
19921985
<desc id="cp-desc-${safeId}">${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
1993-
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
1994-
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
1995-
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
19961986
${renderDefs(sf, params)}
19971987
19981988
<style>

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)