Skip to content

Commit b251324

Browse files
authored
feat: sync Customization Studio settings to URL for shareability (JhaSourav07#2252)
## Description This PR adds URL synchronization to the Customization Studio. Previously, all settings were stored only in component state, meaning configurations could not be bookmarked or shared. Now, as the user adjusts any setting, the page URL updates in real-time using `router.replace`. On page load, the URL search params are read to restore the previous configuration, making every customization fully shareable via a single link. Fixes JhaSourav07#2245 ## Pillar - [x] 🛠️ Other (Bug fix, refactoring, docs) **Changes made:** - Added `useSearchParams` hook to initialize all state from URL params on mount - Added `useEffect` to sync state changes to the URL using `router.replace` with `scroll: false` ## Visual Preview Before: Changing settings in the Customization Studio never updated the page URL. Refreshing the page or sharing the link would lose all configuration. <img width="1920" height="967" alt="{98D0A76E-6735-4DFE-A22B-F14D8B863C29}" src="https://github.com/user-attachments/assets/c9673ec0-08fd-421e-acd1-dd93a862e2fb" /> After: The URL updates in real-time as settings change (e.g. /customize?user=jhasourav07&theme=neon&font=Orbitron). Visiting a shared URL restores the exact same configuration automatically. https://github.com/user-attachments/assets/8c8b46df-035f-4b9f-a6d1-09f53ee1cea2 ## Checklist - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts).
2 parents 401717f + 83861a8 commit b251324

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

app/customize/page.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ vi.mock('framer-motion', () => ({
3434
},
3535
}));
3636

37+
vi.mock('next/navigation', () => ({
38+
useRouter: () => ({
39+
replace: vi.fn(),
40+
}),
41+
useSearchParams: () => ({
42+
get: () => null,
43+
}),
44+
}));
45+
3746
vi.mock('@/components/InteractiveViewer', () => ({
3847
default: ({ children, ...props }: MockContainerProps) => <div {...props}>{children}</div>,
3948
}));

app/customize/page.tsx

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

3-
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react';
3+
import { useCallback, useEffect, useRef, useState, Suspense, type ReactElement } from 'react';
4+
import { useRouter, useSearchParams } from 'next/navigation';
45
import { validateGitHubUsername } from '@/lib/validations';
56
import Link from 'next/link';
67
import { motion } from 'framer-motion';
@@ -23,7 +24,7 @@ import { getExportSnippet, buildQueryParams } from './utils';
2324

2425
// ─── Main Page ────────────────────────────────────────────────────────────────
2526

26-
export default function CustomizePage(): ReactElement {
27+
function CustomizePageInner(): ReactElement {
2728
const [username, setUsername] = useState('');
2829
const [theme, setTheme] = useState('dark');
2930
const [bgHex, setBgHex] = useState('');
@@ -56,6 +57,39 @@ export default function CustomizePage(): ReactElement {
5657
const hasUsername = trimmedUsername.length > 0;
5758
const isRandomTheme = theme === 'random';
5859

60+
const router = useRouter();
61+
const searchParams = useSearchParams();
62+
63+
// On mount: initialize state from URL search params
64+
/* eslint-disable react-hooks/set-state-in-effect */
65+
useEffect(() => {
66+
const u = searchParams.get('user') ?? '';
67+
const t = searchParams.get('theme') ?? 'dark';
68+
setUsername(u);
69+
setTheme(t);
70+
setBgHex(searchParams.get('bg') ?? '');
71+
setAccentHex(searchParams.get('accent') ?? '');
72+
setTextHex(searchParams.get('text') ?? '');
73+
setScale((searchParams.get('scale') as Scale) ?? 'linear');
74+
setSpeed(searchParams.get('speed') ?? '8s');
75+
setFont((searchParams.get('font') as Font) ?? 'Inter');
76+
setYear(searchParams.get('year') ?? '');
77+
setRadius(Number(searchParams.get('radius') ?? 8));
78+
setSize((searchParams.get('size') as BadgeSize) ?? 'medium');
79+
setHideTitle(searchParams.get('hide_title') === 'true');
80+
setHideBackground(searchParams.get('hide_background') === 'true');
81+
setHideStats(searchParams.get('hide_stats') === 'true');
82+
setViewMode((searchParams.get('view') as ViewMode) ?? 'default');
83+
setDeltaFormat((searchParams.get('delta_format') as DeltaFormat) ?? 'percent');
84+
setBadgeWidth(searchParams.get('width') ? Number(searchParams.get('width')) : '');
85+
setBadgeHeight(searchParams.get('height') ? Number(searchParams.get('height')) : '');
86+
setGrace(Number(searchParams.get('grace') ?? 1));
87+
setLanguage((searchParams.get('lang') as Language) ?? 'en');
88+
setTimezone((searchParams.get('tz') as Timezone) ?? 'UTC');
89+
// eslint-disable-next-line react-hooks/exhaustive-deps
90+
}, []);
91+
/* eslint-enable react-hooks/set-state-in-effect */
92+
5993
useEffect(() => {
6094
return () => {
6195
if (copyResetTimeoutRef.current !== null) {
@@ -114,6 +148,12 @@ export default function CustomizePage(): ReactElement {
114148
});
115149
const previewSrc = `/api/streak?${queryString}`;
116150

151+
// On change sync state to URL
152+
useEffect(() => {
153+
if (!queryString) return;
154+
router.replace(`/customize?${queryString}`, { scroll: false });
155+
}, [queryString, router]);
156+
117157
useEffect(() => {
118158
// eslint-disable-next-line react-hooks/set-state-in-effect
119159
setErrorMessage(null);
@@ -573,3 +613,11 @@ export default function CustomizePage(): ReactElement {
573613
</div>
574614
);
575615
}
616+
617+
export default function CustomizePage(): ReactElement {
618+
return (
619+
<Suspense fallback={<div className="min-h-screen bg-transparent" />}>
620+
<CustomizePageInner />
621+
</Suspense>
622+
);
623+
}

0 commit comments

Comments
 (0)