Skip to content

Commit 6c0c61d

Browse files
authored
refactor: split customize studio components (JhaSourav07#95)
## Description Fixes JhaSourav07#93 Refactors the Customize Studio page so `app/customize/page.tsx` acts as the state/orchestration layer while internal types, helpers, and UI pieces live in dedicated files. This also adds a clean Markdown/HTML export toggle so users can copy the active snippet format from the export panel. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview This PR does not change the generated SVG output. It updates the Customize Studio UI by replacing the old Markdown-only export block with an export panel that can switch between: - Markdown snippet: `![CommitPulse](...)` - HTML snippet: `<img src="..." alt="CommitPulse" />` The live badge preview and active parameters panel remain unchanged. ## Checklist before requesting a review: - [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): ...`). - [x] 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). Validation run: - `prettier --check` on touched Customize Studio files - `npm run typecheck` - `npm run lint` - `npm run test` (10 files, 111 tests passed) - `npm run build`
2 parents 508143c + 2f3027f commit 6c0c61d

7 files changed

Lines changed: 510 additions & 354 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import type { ReactElement, ReactNode } from 'react';
2+
import { SPEEDS, type Scale } from '../types';
3+
import { isValidHex, stripHash } from '../utils';
4+
import { SectionLabel } from './SectionLabel';
5+
import { StyledSelect, ThemeSelector } from './ThemeSelector';
6+
7+
function ControlRow({ label, children }: { label: string; children: ReactNode }): ReactElement {
8+
return (
9+
<div className="flex flex-col gap-1.5">
10+
<SectionLabel>{label}</SectionLabel>
11+
{children}
12+
</div>
13+
);
14+
}
15+
16+
function HexInput({
17+
id,
18+
label,
19+
value,
20+
onChange,
21+
placeholder,
22+
}: {
23+
id: string;
24+
label: string;
25+
value: string;
26+
onChange: (v: string) => void;
27+
placeholder: string;
28+
}): ReactElement {
29+
const pickerValue = isValidHex(value) ? `#${stripHash(value)}` : '#000000';
30+
const swatchColor = isValidHex(value) ? pickerValue : null;
31+
32+
return (
33+
<div className="flex flex-col gap-1.5">
34+
<SectionLabel>{label}</SectionLabel>
35+
<div className="relative flex items-center gap-2">
36+
<label
37+
htmlFor={`${id}-picker`}
38+
title="Open color picker"
39+
className="relative shrink-0 w-9 h-9 rounded-xl border border-white/10 overflow-hidden cursor-pointer hover:border-emerald-500/50 transition-colors"
40+
style={{ backgroundColor: swatchColor ?? '#1a1a1a' }}
41+
>
42+
{!swatchColor && (
43+
<span
44+
className="absolute inset-0"
45+
style={{
46+
backgroundImage: 'repeating-conic-gradient(#333 0% 25%, #1a1a1a 0% 50%)',
47+
backgroundSize: '8px 8px',
48+
}}
49+
/>
50+
)}
51+
<input
52+
id={`${id}-picker`}
53+
type="color"
54+
value={pickerValue}
55+
onChange={(e) => onChange(stripHash(e.target.value))}
56+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
57+
aria-label={`Color picker for ${label}`}
58+
/>
59+
</label>
60+
61+
<div className="relative flex-1 flex items-center">
62+
<span className="absolute left-3 text-white/30 text-sm select-none pointer-events-none">
63+
#
64+
</span>
65+
<input
66+
id={id}
67+
type="text"
68+
value={value}
69+
onChange={(e) => onChange(e.target.value.replace(/^#/, ''))}
70+
placeholder={placeholder.replace(/^#/, '')}
71+
maxLength={6}
72+
className="w-full bg-black border border-white/10 rounded-xl pl-7 pr-4 py-2.5 text-sm font-mono text-emerald-300 placeholder:text-white/20 outline-none focus:border-emerald-500/50 transition-colors"
73+
/>
74+
</div>
75+
</div>
76+
</div>
77+
);
78+
}
79+
80+
export function ControlsPanel({
81+
username,
82+
theme,
83+
bgHex,
84+
accentHex,
85+
textHex,
86+
scale,
87+
speed,
88+
onUsernameChange,
89+
onThemeChange,
90+
onBgHexChange,
91+
onAccentHexChange,
92+
onTextHexChange,
93+
onScaleChange,
94+
onSpeedChange,
95+
onClearOverrides,
96+
}: {
97+
username: string;
98+
theme: string;
99+
bgHex: string;
100+
accentHex: string;
101+
textHex: string;
102+
scale: Scale;
103+
speed: string;
104+
onUsernameChange: (value: string) => void;
105+
onThemeChange: (value: string) => void;
106+
onBgHexChange: (value: string) => void;
107+
onAccentHexChange: (value: string) => void;
108+
onTextHexChange: (value: string) => void;
109+
onScaleChange: (value: Scale) => void;
110+
onSpeedChange: (value: string) => void;
111+
onClearOverrides: () => void;
112+
}): ReactElement {
113+
const hasOverrides = Boolean(bgHex || accentHex || textHex);
114+
115+
return (
116+
<div>
117+
<p className="text-xs font-bold uppercase tracking-[0.22em] text-emerald-400 mb-4">
118+
Controls
119+
</p>
120+
121+
<div className="flex flex-col gap-5">
122+
<ControlRow label="GitHub Username">
123+
<input
124+
id="username-input"
125+
type="text"
126+
value={username}
127+
onChange={(e) => onUsernameChange(e.target.value)}
128+
placeholder="jhasourav07"
129+
className="w-full bg-black border border-white/10 rounded-xl px-4 py-2.5 text-sm font-mono text-emerald-300 placeholder:text-white/20 outline-none focus:border-emerald-500/50 transition-colors"
130+
/>
131+
</ControlRow>
132+
133+
<div className="h-px bg-white/5" />
134+
135+
<ThemeSelector theme={theme} onThemeChange={onThemeChange} />
136+
137+
<div className="h-px bg-white/5" />
138+
139+
<div>
140+
<SectionLabel>Custom Color Overrides</SectionLabel>
141+
<p className="text-[11px] text-white/25 mb-3 leading-relaxed">
142+
These override the theme preset above. Enter HEX values without&nbsp;
143+
<code className="text-white/40">#</code>.
144+
</p>
145+
<div className="flex flex-col gap-3">
146+
<HexInput
147+
id="bg-hex-input"
148+
label="Background"
149+
value={bgHex}
150+
onChange={onBgHexChange}
151+
placeholder="e.g. 0a0a0a"
152+
/>
153+
<HexInput
154+
id="accent-hex-input"
155+
label="Accent / Tower Color"
156+
value={accentHex}
157+
onChange={onAccentHexChange}
158+
placeholder="e.g. 00ffaa"
159+
/>
160+
<HexInput
161+
id="text-hex-input"
162+
label="Text / Label Color"
163+
value={textHex}
164+
onChange={onTextHexChange}
165+
placeholder="e.g. ffffff"
166+
/>
167+
</div>
168+
{hasOverrides && (
169+
<button
170+
id="clear-overrides-btn"
171+
onClick={onClearOverrides}
172+
className="mt-3 text-[11px] text-red-400/60 hover:text-red-400 transition-colors"
173+
>
174+
Clear overrides
175+
</button>
176+
)}
177+
</div>
178+
179+
<div className="h-px bg-white/5" />
180+
181+
<ControlRow label="Tower Height Scaling">
182+
<div className="grid grid-cols-2 gap-2">
183+
{(['linear', 'log'] as Scale[]).map((currentScale) => (
184+
<button
185+
key={currentScale}
186+
id={`scale-${currentScale}-btn`}
187+
onClick={() => onScaleChange(currentScale)}
188+
className={`py-2.5 rounded-xl text-sm font-bold transition-all ${
189+
scale === currentScale
190+
? 'bg-emerald-500/15 border border-emerald-500/30 text-emerald-400'
191+
: 'bg-black border border-white/8 text-white/30 hover:text-white/60 hover:border-white/20'
192+
}`}
193+
>
194+
{currentScale === 'linear' ? 'Linear' : 'Logarithmic'}
195+
</button>
196+
))}
197+
</div>
198+
<p className="text-[11px] text-white/25 mt-1.5 leading-relaxed">
199+
{scale === 'log'
200+
? 'Log mode compresses extreme outliers. Great for power committers.'
201+
: 'Linear mode shows raw commit counts as tower heights.'}
202+
</p>
203+
</ControlRow>
204+
205+
<ControlRow label="Radar Scan Speed">
206+
<div className="relative">
207+
<StyledSelect id="speed-select" value={speed} onChange={onSpeedChange}>
208+
{SPEEDS.map((speedOption) => (
209+
<option key={speedOption.value} value={speedOption.value}>
210+
{speedOption.label}
211+
</option>
212+
))}
213+
</StyledSelect>
214+
</div>
215+
</ControlRow>
216+
</div>
217+
</div>
218+
);
219+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { ReactElement } from 'react';
2+
import type { ExportFormat } from '../types';
3+
import { getPlaceholderSnippet } from '../utils';
4+
5+
const EXPORT_FORMATS: { value: ExportFormat; label: string }[] = [
6+
{ value: 'markdown', label: 'Markdown' },
7+
{ value: 'html', label: 'HTML' },
8+
];
9+
10+
export function ExportPanel({
11+
format,
12+
snippet,
13+
copied,
14+
hasUsername,
15+
onFormatChange,
16+
onCopy,
17+
}: {
18+
format: ExportFormat;
19+
snippet: string;
20+
copied: boolean;
21+
hasUsername: boolean;
22+
onFormatChange: (format: ExportFormat) => void;
23+
onCopy: () => void;
24+
}): ReactElement {
25+
const activeSnippet = hasUsername ? snippet : getPlaceholderSnippet(format);
26+
const formatLabel = format === 'markdown' ? 'Markdown' : 'HTML';
27+
28+
return (
29+
<div className="bg-[#0a0a0a] border border-white/5 rounded-[1.75rem] p-6">
30+
<div className="flex flex-col gap-4 mb-5 md:flex-row md:items-center md:justify-between">
31+
<div>
32+
<p className="text-xs font-bold uppercase tracking-[0.22em] text-emerald-400">
33+
{formatLabel} Export Snippet
34+
</p>
35+
<p className="mt-1 text-[11px] text-white/25">
36+
Switch formats without changing the live badge configuration.
37+
</p>
38+
</div>
39+
40+
<div className="flex flex-wrap items-center gap-3">
41+
<div
42+
className="inline-flex rounded-xl border border-white/10 bg-black p-1"
43+
aria-label="Export format"
44+
>
45+
{EXPORT_FORMATS.map((option) => (
46+
<button
47+
key={option.value}
48+
type="button"
49+
onClick={() => onFormatChange(option.value)}
50+
aria-pressed={format === option.value}
51+
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${
52+
format === option.value
53+
? 'bg-emerald-500/15 text-emerald-300 shadow-[0_0_24px_rgba(16,185,129,0.16)]'
54+
: 'text-white/35 hover:text-white/70'
55+
}`}
56+
>
57+
{option.label}
58+
</button>
59+
))}
60+
</div>
61+
62+
<button
63+
id="copy-markdown-btn"
64+
onClick={onCopy}
65+
disabled={!hasUsername}
66+
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
67+
!hasUsername
68+
? 'bg-white/[0.04] border border-white/8 text-white/30'
69+
: copied
70+
? 'bg-emerald-500/15 border border-emerald-500/30 text-emerald-400'
71+
: 'bg-white text-black hover:scale-[1.03] active:scale-[0.97]'
72+
}`}
73+
>
74+
{copied ? (
75+
<>
76+
<svg
77+
xmlns="http://www.w3.org/2000/svg"
78+
className="w-3.5 h-3.5"
79+
viewBox="0 0 24 24"
80+
fill="none"
81+
stroke="currentColor"
82+
strokeWidth="3"
83+
strokeLinecap="round"
84+
strokeLinejoin="round"
85+
aria-hidden="true"
86+
>
87+
<polyline points="20 6 9 17 4 12" />
88+
</svg>
89+
Copied!
90+
</>
91+
) : (
92+
<>
93+
<svg
94+
xmlns="http://www.w3.org/2000/svg"
95+
className="w-3.5 h-3.5"
96+
viewBox="0 0 24 24"
97+
fill="none"
98+
stroke="currentColor"
99+
strokeWidth="2.5"
100+
strokeLinecap="round"
101+
strokeLinejoin="round"
102+
aria-hidden="true"
103+
>
104+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
105+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
106+
</svg>
107+
Copy {formatLabel}
108+
</>
109+
)}
110+
</button>
111+
</div>
112+
</div>
113+
114+
<div className="bg-black/60 border border-white/8 rounded-xl px-5 py-4 overflow-x-auto">
115+
<code className="text-emerald-300 text-xs font-mono leading-relaxed break-all whitespace-pre-wrap">
116+
{activeSnippet}
117+
</code>
118+
</div>
119+
120+
<p className="mt-4 text-[11px] text-white/20 leading-relaxed">
121+
Paste this into your GitHub profile&apos;s <code className="text-white/35">README.md</code>.
122+
The badge renders server-side, no script required.
123+
</p>
124+
</div>
125+
);
126+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { ReactElement, ReactNode } from 'react';
2+
3+
export function SectionLabel({ children }: { children: ReactNode }): ReactElement {
4+
return (
5+
<p className="text-[10px] font-bold uppercase tracking-[0.22em] text-white/30 mb-2">
6+
{children}
7+
</p>
8+
);
9+
}

0 commit comments

Comments
 (0)