Skip to content

Commit efd1ec4

Browse files
authored
merge: feat(customize): Add Export Options for PNG, WebP, and PDF in Customizer Generator (#8245)
## Description Fixes #8243 Added PNG, WebP, and PDF export capabilities to the Customizer generator page (`/customize`). Users can now download high-resolution raster images (PNG & WebP) and vector documents (PDF) directly from their browser with instantaneous toast feedback, retaining exact theme colors, custom gradients, dimensions, and typography. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview - **Export Action Bar**: Added `Download PNG`, `Download WebP`, and `Download PDF` action buttons to `ExportPanel.tsx`. - **Toast Feedback**: Instant `sonner` notifications triggered on successful/failed export. ## 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). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 7bcc0c5 + ed6fccf commit efd1ec4

12 files changed

Lines changed: 209 additions & 69 deletions

File tree

app/customize/components/ExportPanel.test.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ describe('ExportPanel', () => {
126126
expect(onCopy).toHaveBeenCalledTimes(1);
127127
expect(copyButton.getAttribute('aria-describedby')).toBe('export-copy-status');
128128
expect(screen.getByRole('status').textContent).toBe('Markdown snippet copied to clipboard.');
129-
expect(screen.getByText('Copied!')).toBeDefined();
129+
});
130+
131+
it('renders PNG, WebP, and PDF download buttons', () => {
132+
renderPanel();
133+
134+
expect(screen.getByRole('button', { name: 'Download PNG' })).toBeDefined();
135+
expect(screen.getByRole('button', { name: 'Download WebP' })).toBeDefined();
136+
expect(screen.getByRole('button', { name: 'Download PDF' })).toBeDefined();
137+
});
138+
139+
it('disables export download buttons when hasUsername is false', () => {
140+
renderPanel({ hasUsername: false });
141+
142+
const pngBtn = screen.getByRole('button', { name: 'Download PNG' }) as HTMLButtonElement;
143+
const webpBtn = screen.getByRole('button', { name: 'Download WebP' }) as HTMLButtonElement;
144+
const pdfBtn = screen.getByRole('button', { name: 'Download PDF' }) as HTMLButtonElement;
145+
146+
expect(pngBtn.disabled).toBe(true);
147+
expect(webpBtn.disabled).toBe(true);
148+
expect(pdfBtn.disabled).toBe(true);
130149
});
131150
});

app/customize/components/ExportPanel.tsx

Lines changed: 143 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ const EXPORT_FORMATS: { value: ExportFormat; labelKey: string }[] = [
1515
{ value: 'action', labelKey: 'action' },
1616
];
1717

18+
function resolveBadgeUrl(rawUrl: string): string {
19+
const cleaned = rawUrl.replace(/&/g, '&');
20+
try {
21+
const urlObj = new URL(cleaned, window.location.origin);
22+
if (urlObj.hostname === 'commitpulse.vercel.app') {
23+
const originObj = new URL(window.location.origin);
24+
urlObj.protocol = originObj.protocol;
25+
urlObj.host = originObj.host;
26+
}
27+
return urlObj.toString();
28+
} catch {
29+
return cleaned;
30+
}
31+
}
32+
1833
export function ExportPanel({
1934
format,
2035
snippet,
@@ -116,13 +131,8 @@ export function ExportPanel({
116131
return;
117132
}
118133

119-
// 2. Clear out HTML character entities if grabbed from HTML embed strings
120-
targetUrl = targetUrl.replace(/&/g, '&');
121-
122-
// 3. SECURE LOCAL WORKSPACE TESTING: Redirect backend calls to your local server instance
123-
if (targetUrl.includes('https://commitpulse.vercel.app')) {
124-
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
125-
}
134+
// 2. Clear out HTML character entities & resolve local origin
135+
targetUrl = resolveBadgeUrl(targetUrl);
126136

127137
// 4. Append a cache-busting refresh query parameter to guarantee the latest custom colors
128138
if (targetUrl.includes('?')) {
@@ -194,21 +204,30 @@ export function ExportPanel({
194204

195205
try {
196206
setIsDownloading(true);
207+
const target = document.querySelector<HTMLElement>('#export-container');
208+
209+
if (target) {
210+
const { toPng } = await import('html-to-image');
211+
const pngUrl = await toPng(target, { pixelRatio: 2 });
212+
const link = document.createElement('a');
213+
link.href = pngUrl;
214+
link.download = `commitpulse-${username || 'badge'}.png`;
215+
document.body.appendChild(link);
216+
link.click();
217+
document.body.removeChild(link);
218+
toast.success('Badge PNG downloaded successfully!');
219+
return;
220+
}
197221

198222
const urlMatch = snippet.match(/\((https?:\/\/[^)]+)\)/) || snippet.match(/src="([^"]+)"/);
199-
200223
let targetUrl = urlMatch ? urlMatch[1] : '';
201224

202225
if (!targetUrl) {
203226
toast.error('Could not determine badge URL.');
204227
return;
205228
}
206229

207-
targetUrl = targetUrl.replace(/&amp;/g, '&');
208-
209-
if (targetUrl.includes('https://commitpulse.vercel.app')) {
210-
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
211-
}
230+
targetUrl = resolveBadgeUrl(targetUrl);
212231

213232
if (targetUrl.includes('?')) {
214233
targetUrl += '&format=png';
@@ -234,6 +253,7 @@ export function ExportPanel({
234253
document.body.removeChild(link);
235254

236255
URL.revokeObjectURL(pngUrl);
256+
toast.success('Badge PNG downloaded successfully!');
237257
} catch (error) {
238258
console.error(error);
239259
toast.error('Failed to download PNG badge.');
@@ -242,6 +262,83 @@ export function ExportPanel({
242262
}
243263
};
244264

265+
const handleDownloadWebp = async () => {
266+
if (!hasUsername || !snippet) return;
267+
268+
try {
269+
setIsDownloading(true);
270+
const target = document.querySelector<HTMLElement>('#export-container');
271+
272+
if (target) {
273+
const { toCanvas } = await import('html-to-image');
274+
const canvas = await toCanvas(target, { pixelRatio: 2 });
275+
const webpUrl = canvas.toDataURL('image/webp');
276+
const link = document.createElement('a');
277+
link.href = webpUrl;
278+
link.download = `commitpulse-${username || 'badge'}.webp`;
279+
document.body.appendChild(link);
280+
link.click();
281+
document.body.removeChild(link);
282+
toast.success('Badge WebP downloaded successfully!');
283+
return;
284+
}
285+
286+
toast.error('Preview element not found for WebP conversion.');
287+
} catch (error) {
288+
console.error('WebP export error:', error);
289+
toast.error('Failed to download WebP badge.');
290+
} finally {
291+
setIsDownloading(false);
292+
}
293+
};
294+
295+
const handleDownloadPdf = async () => {
296+
if (!hasUsername || !snippet) return;
297+
298+
try {
299+
setIsDownloading(true);
300+
const target = document.querySelector<HTMLElement>('#export-container');
301+
let svgMarkup = target?.innerHTML || '';
302+
303+
if (!svgMarkup || !svgMarkup.includes('<svg')) {
304+
const urlMatch = snippet.match(/\((https?:\/\/[^)]+)\)/) || snippet.match(/src="([^"]+)"/);
305+
let targetUrl = urlMatch ? urlMatch[1] : '';
306+
307+
if (targetUrl) {
308+
targetUrl = resolveBadgeUrl(targetUrl);
309+
310+
const response = await fetch(targetUrl);
311+
312+
if (response.ok) {
313+
svgMarkup = await response.text();
314+
}
315+
}
316+
}
317+
318+
if (!svgMarkup) {
319+
toast.error('Could not determine badge content for PDF export.');
320+
return;
321+
}
322+
323+
const { default: JsPDF } = await import('jspdf');
324+
const { exportSvgToPdf } = await import('@/lib/pdf-export');
325+
326+
const pdf = new JsPDF({
327+
orientation: 'landscape',
328+
unit: 'pt',
329+
format: 'a4',
330+
});
331+
332+
await exportSvgToPdf(svgMarkup, `commitpulse-${username || 'badge'}.pdf`, pdf);
333+
toast.success('Badge PDF downloaded successfully!');
334+
} catch (error) {
335+
console.error('PDF export error:', error);
336+
toast.error('Failed to download PDF badge.');
337+
} finally {
338+
setIsDownloading(false);
339+
}
340+
};
341+
245342
return (
246343
<div className="flex flex-col gap-4">
247344
{/* Code Block Header Control Deck */}
@@ -320,6 +417,7 @@ export function ExportPanel({
320417
</button>
321418

322419
<button
420+
id="download-png-btn"
323421
type="button"
324422
onClick={handleDownloadPng}
325423
disabled={!hasUsername || isDownloading || format === 'action'}
@@ -334,6 +432,38 @@ export function ExportPanel({
334432
: t('customize.export.download_png', { defaultValue: 'Download PNG' })}
335433
</button>
336434

435+
<button
436+
id="download-webp-btn"
437+
type="button"
438+
onClick={handleDownloadWebp}
439+
disabled={!hasUsername || isDownloading || format === 'action'}
440+
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
441+
!hasUsername || isDownloading || format === 'action'
442+
? 'bg-gray-200/90 border border-black/10 text-gray-500 cursor-not-allowed dark:bg-white/10 dark:border-white/10 dark:text-white/35'
443+
: 'bg-teal-500/10 border border-teal-500/30 text-teal-500 hover:bg-teal-500/20 hover:scale-[1.03] active:scale-[0.97]'
444+
}`}
445+
>
446+
{isDownloading
447+
? t('customize.export.downloading', { defaultValue: 'Downloading...' })
448+
: t('customize.export.download_webp', { defaultValue: 'Download WebP' })}
449+
</button>
450+
451+
<button
452+
id="download-pdf-btn"
453+
type="button"
454+
onClick={handleDownloadPdf}
455+
disabled={!hasUsername || isDownloading || format === 'action'}
456+
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
457+
!hasUsername || isDownloading || format === 'action'
458+
? 'bg-gray-200/90 border border-black/10 text-gray-500 cursor-not-allowed dark:bg-white/10 dark:border-white/10 dark:text-white/35'
459+
: 'bg-rose-500/10 border border-rose-500/30 text-rose-500 hover:bg-rose-500/20 hover:scale-[1.03] active:scale-[0.97]'
460+
}`}
461+
>
462+
{isDownloading
463+
? t('customize.export.downloading', { defaultValue: 'Downloading...' })
464+
: t('customize.export.download_pdf', { defaultValue: 'Download PDF' })}
465+
</button>
466+
337467
{/* Share Configuration Button */}
338468
<button
339469
type="button"

locales/de.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@
160160
"footer_tip": "Füge dies in die README.md deines GitHub-Profils ein. Das Badge wird serverseitig gerendert, kein Skript erforderlich.",
161161
"tsx": "React TSX",
162162
"download_svg": "SVG herunterladen",
163-
"download_png": "PNG herunterladen"
163+
"download_png": "PNG herunterladen",
164+
"download_webp": "WebP herunterladen",
165+
"download_pdf": "PDF herunterladen",
166+
"export_as": "Exportieren als"
164167
}
165168
},
166169
"dashboard": {

locales/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@
160160
"footer_tip": "The badge renders server-side, no script required.",
161161
"tsx": "React TSX",
162162
"download_svg": "Download SVG",
163-
"download_png": "Download PNG"
163+
"download_png": "Download PNG",
164+
"download_webp": "Download WebP",
165+
"download_pdf": "Download PDF",
166+
"export_as": "Export As"
164167
}
165168
},
166169
"dashboard": {

locales/es.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@
159159
"copy_aria_enabled": "Copiar fragmento de exportación {{format}} al portapapeles",
160160
"footer_tip": "Pega esto en el README.md de tu perfil de GitHub. La insignia se genera en el servidor, no requiere scripts.",
161161
"tsx": "React TSX",
162-
"download_svg": "Download SVG",
163-
"download_png": "Download PNG"
162+
"download_svg": "Descargar SVG",
163+
"download_png": "Descargar PNG",
164+
"download_webp": "Descargar WebP",
165+
"download_pdf": "Descargar PDF",
166+
"export_as": "Exportar como"
164167
}
165168
},
166169
"dashboard": {

locales/fr.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@
159159
"copy_aria_enabled": "Copier l'extrait d'exportation {{format}} dans le presse-papiers",
160160
"footer_tip": "Collez ceci dans le fichier README.md de votre profil GitHub. Le badge est généré côté serveur, aucun script n'est requis.",
161161
"tsx": "React TSX",
162-
"download_svg": "Download SVG",
163-
"download_png": "Download PNG"
162+
"download_svg": "Télécharger SVG",
163+
"download_png": "Télécharger PNG",
164+
"download_webp": "Télécharger WebP",
165+
"download_pdf": "Télécharger PDF",
166+
"export_as": "Exporter sous"
164167
}
165168
},
166169
"dashboard": {

locales/hi.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@
159159
"copy_aria_enabled": "निर्यात स्निपेट {{format}} क्लिपबोर्ड पर कॉपी करें",
160160
"footer_tip": "इसे अपनी गिटहब प्रोफ़ाइल के README.md में पेस्ट करें। बैज सर्वर-साइड रेंडर होता है, किसी स्क्रिप्ट की आवश्यकता नहीं है।",
161161
"tsx": "React TSX",
162-
"download_svg": "Download SVG",
163-
"download_png": "Download PNG"
162+
"download_svg": "SVG डाउनलोड करें",
163+
"download_png": "PNG डाउनलोड करें",
164+
"download_webp": "WebP डाउनलोड करें",
165+
"download_pdf": "PDF डाउनलोड करें",
166+
"export_as": "इस रूप में निर्यात करें"
164167
}
165168
},
166169
"dashboard": {

locales/ja.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@
159159
"copy_aria_enabled": "{{format}}エクスポートスニペットをクリップボードにコピー",
160160
"footer_tip": "これをGitHubプロフィールのREADME.mdに貼り付けます。バッジはサーバーサイドでレンダリングされるため、スクリプトは不要です。",
161161
"tsx": "React TSX",
162-
"download_svg": "Download SVG",
163-
"download_png": "Download PNG"
162+
"download_svg": "SVGをダウンロード",
163+
"download_png": "PNGをダウンロード",
164+
"download_webp": "WebPをダウンロード",
165+
"download_pdf": "PDFをダウンロード",
166+
"export_as": "形式を選択してエクスポート"
164167
}
165168
},
166169
"dashboard": {

locales/ko.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@
159159
"copy_aria_enabled": "{{format}} 내보내기 스니펫을 클립보드에 복사",
160160
"footer_tip": "이것을 GitHub 프로필 README.md 파일에 붙여넣으세요. 배지는 서버 측에서 렌더링되므로 스크립트가 필요하지 않습니다.",
161161
"tsx": "React TSX",
162-
"download_svg": "Download SVG",
163-
"download_png": "Download PNG"
162+
"download_svg": "SVG 다운로드",
163+
"download_png": "PNG 다운로드",
164+
"download_webp": "WebP 다운로드",
165+
"download_pdf": "PDF 다운로드",
166+
"export_as": "다른 형식으로 내보내기"
164167
}
165168
},
166169
"dashboard": {

locales/pt.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@
160160
"footer_tip": "Cole isso no README.md do perfil do GitHub. O emblema é renderizado no lado do servidor, sem necessidade de script.",
161161
"tsx": "React TSX",
162162
"download_svg": "Baixar SVG",
163-
"download_png": "Baixar PNG"
163+
"download_png": "Baixar PNG",
164+
"download_webp": "Baixar WebP",
165+
"download_pdf": "Baixar PDF",
166+
"export_as": "Exportar como"
164167
}
165168
},
166169
"dashboard": {

0 commit comments

Comments
 (0)