Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/customize/components/ExportPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,25 @@ describe('ExportPanel', () => {
expect(onCopy).toHaveBeenCalledTimes(1);
expect(copyButton.getAttribute('aria-describedby')).toBe('export-copy-status');
expect(screen.getByRole('status').textContent).toBe('Markdown snippet copied to clipboard.');
expect(screen.getByText('Copied!')).toBeDefined();
});

it('renders PNG, WebP, and PDF download buttons', () => {
renderPanel();

expect(screen.getByRole('button', { name: 'Download PNG' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Download WebP' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Download PDF' })).toBeDefined();
});

it('disables export download buttons when hasUsername is false', () => {
renderPanel({ hasUsername: false });

const pngBtn = screen.getByRole('button', { name: 'Download PNG' }) as HTMLButtonElement;
const webpBtn = screen.getByRole('button', { name: 'Download WebP' }) as HTMLButtonElement;
const pdfBtn = screen.getByRole('button', { name: 'Download PDF' }) as HTMLButtonElement;

expect(pngBtn.disabled).toBe(true);
expect(webpBtn.disabled).toBe(true);
expect(pdfBtn.disabled).toBe(true);
});
});
158 changes: 144 additions & 14 deletions app/customize/components/ExportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ const EXPORT_FORMATS: { value: ExportFormat; labelKey: string }[] = [
{ value: 'action', labelKey: 'action' },
];

function resolveBadgeUrl(rawUrl: string): string {
const cleaned = rawUrl.replace(/&/g, '&');
try {
const urlObj = new URL(cleaned, window.location.origin);
if (urlObj.hostname === 'commitpulse.vercel.app') {
const originObj = new URL(window.location.origin);
urlObj.protocol = originObj.protocol;
urlObj.host = originObj.host;
}
return urlObj.toString();
} catch {
return cleaned;
}
}

export function ExportPanel({
format,
snippet,
Expand Down Expand Up @@ -116,13 +131,8 @@ export function ExportPanel({
return;
}

// 2. Clear out HTML character entities if grabbed from HTML embed strings
targetUrl = targetUrl.replace(/&/g, '&');

// 3. SECURE LOCAL WORKSPACE TESTING: Redirect backend calls to your local server instance
if (targetUrl.includes('https://commitpulse.vercel.app')) {
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
}
// 2. Clear out HTML character entities & resolve local origin
targetUrl = resolveBadgeUrl(targetUrl);

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

try {
setIsDownloading(true);
const target = document.querySelector<HTMLElement>('#export-container');

if (target) {
const { toPng } = await import('html-to-image');
const pngUrl = await toPng(target, { pixelRatio: 2 });
const link = document.createElement('a');
link.href = pngUrl;
link.download = `commitpulse-${username || 'badge'}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('Badge PNG downloaded successfully!');
return;
}

const urlMatch = snippet.match(/\((https?:\/\/[^)]+)\)/) || snippet.match(/src="([^"]+)"/);

let targetUrl = urlMatch ? urlMatch[1] : '';

if (!targetUrl) {
toast.error('Could not determine badge URL.');
return;
}

targetUrl = targetUrl.replace(/&amp;/g, '&');

if (targetUrl.includes('https://commitpulse.vercel.app')) {
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
}
targetUrl = resolveBadgeUrl(targetUrl);

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

URL.revokeObjectURL(pngUrl);
toast.success('Badge PNG downloaded successfully!');
} catch (error) {
console.error(error);
toast.error('Failed to download PNG badge.');
Expand All @@ -242,11 +262,88 @@ export function ExportPanel({
}
};

const handleDownloadWebp = async () => {
if (!hasUsername || !snippet) return;

try {
setIsDownloading(true);
const target = document.querySelector<HTMLElement>('#export-container');

if (target) {
const { toCanvas } = await import('html-to-image');
const canvas = await toCanvas(target, { pixelRatio: 2 });
const webpUrl = canvas.toDataURL('image/webp');
const link = document.createElement('a');
link.href = webpUrl;
link.download = `commitpulse-${username || 'badge'}.webp`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('Badge WebP downloaded successfully!');
return;
}

toast.error('Preview element not found for WebP conversion.');
} catch (error) {
console.error('WebP export error:', error);
toast.error('Failed to download WebP badge.');
} finally {
setIsDownloading(false);
}
};

const handleDownloadPdf = async () => {
if (!hasUsername || !snippet) return;

try {
setIsDownloading(true);
const target = document.querySelector<HTMLElement>('#export-container');
let svgMarkup = target?.innerHTML || '';

if (!svgMarkup || !svgMarkup.includes('<svg')) {
const urlMatch = snippet.match(/\((https?:\/\/[^)]+)\)/) || snippet.match(/src="([^"]+)"/);
let targetUrl = urlMatch ? urlMatch[1] : '';

if (targetUrl) {
targetUrl = resolveBadgeUrl(targetUrl);

const response = await fetch(targetUrl);

if (response.ok) {
svgMarkup = await response.text();
}
}
}

if (!svgMarkup) {
toast.error('Could not determine badge content for PDF export.');
return;
}

const { default: JsPDF } = await import('jspdf');
const { exportSvgToPdf } = await import('@/lib/pdf-export');

const pdf = new JsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4',
});

await exportSvgToPdf(svgMarkup, `commitpulse-${username || 'badge'}.pdf`, pdf);
toast.success('Badge PDF downloaded successfully!');
} catch (error) {
console.error('PDF export error:', error);
toast.error('Failed to download PDF badge.');
} finally {
setIsDownloading(false);
}
};

return (
<div className="flex flex-col gap-4">
{/* Code Block Header Control Deck */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<div
className="flex flex-wrap sm:flex-nowrap rounded-xl border border-black/10 bg-white/60 backdrop-blur-md dark:border-white/10 dark:bg-white/[0.03] p-1"
aria-label="Export format"
Expand Down Expand Up @@ -320,6 +417,7 @@ export function ExportPanel({
</button>

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

<button
id="download-webp-btn"
type="button"
onClick={handleDownloadWebp}
disabled={!hasUsername || isDownloading || format === 'action'}
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
!hasUsername || isDownloading || format === 'action'
? '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'
: '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]'
}`}
>
{isDownloading
? t('customize.export.downloading', { defaultValue: 'Downloading...' })
: t('customize.export.download_webp', { defaultValue: 'Download WebP' })}
</button>

<button
id="download-pdf-btn"
type="button"
onClick={handleDownloadPdf}
disabled={!hasUsername || isDownloading || format === 'action'}
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
!hasUsername || isDownloading || format === 'action'
? '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'
: '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]'
}`}
>
{isDownloading
? t('customize.export.downloading', { defaultValue: 'Downloading...' })
: t('customize.export.download_pdf', { defaultValue: 'Download PDF' })}
</button>

{/* Share Configuration Button */}
<button
type="button"
Expand Down
5 changes: 4 additions & 1 deletion locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@
"footer_tip": "Füge dies in die README.md deines GitHub-Profils ein. Das Badge wird serverseitig gerendert, kein Skript erforderlich.",
"tsx": "React TSX",
"download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen"
"download_png": "PNG herunterladen",
"download_webp": "WebP herunterladen",
"download_pdf": "PDF herunterladen",
"export_as": "Exportieren als"
}
},
"dashboard": {
Expand Down
5 changes: 4 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@
"footer_tip": "The badge renders server-side, no script required.",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_png": "Download PNG",
"download_webp": "Download WebP",
"download_pdf": "Download PDF",
"export_as": "Export As"
}
},
"dashboard": {
Expand Down
7 changes: 5 additions & 2 deletions locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@
"copy_aria_enabled": "Copiar fragmento de exportación {{format}} al portapapeles",
"footer_tip": "Pega esto en el README.md de tu perfil de GitHub. La insignia se genera en el servidor, no requiere scripts.",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_svg": "Descargar SVG",
"download_png": "Descargar PNG",
"download_webp": "Descargar WebP",
"download_pdf": "Descargar PDF",
"export_as": "Exportar como"
}
},
"dashboard": {
Expand Down
7 changes: 5 additions & 2 deletions locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@
"copy_aria_enabled": "Copier l'extrait d'exportation {{format}} dans le presse-papiers",
"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.",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_svg": "Télécharger SVG",
"download_png": "Télécharger PNG",
"download_webp": "Télécharger WebP",
"download_pdf": "Télécharger PDF",
"export_as": "Exporter sous"
}
},
"dashboard": {
Expand Down
7 changes: 5 additions & 2 deletions locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@
"copy_aria_enabled": "निर्यात स्निपेट {{format}} क्लिपबोर्ड पर कॉपी करें",
"footer_tip": "इसे अपनी गिटहब प्रोफ़ाइल के README.md में पेस्ट करें। बैज सर्वर-साइड रेंडर होता है, किसी स्क्रिप्ट की आवश्यकता नहीं है।",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_svg": "SVG डाउनलोड करें",
"download_png": "PNG डाउनलोड करें",
"download_webp": "WebP डाउनलोड करें",
"download_pdf": "PDF डाउनलोड करें",
"export_as": "इस रूप में निर्यात करें"
}
},
"dashboard": {
Expand Down
7 changes: 5 additions & 2 deletions locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@
"copy_aria_enabled": "{{format}}エクスポートスニペットをクリップボードにコピー",
"footer_tip": "これをGitHubプロフィールのREADME.mdに貼り付けます。バッジはサーバーサイドでレンダリングされるため、スクリプトは不要です。",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_svg": "SVGをダウンロード",
"download_png": "PNGをダウンロード",
"download_webp": "WebPをダウンロード",
"download_pdf": "PDFをダウンロード",
"export_as": "形式を選択してエクスポート"
}
},
"dashboard": {
Expand Down
7 changes: 5 additions & 2 deletions locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@
"copy_aria_enabled": "{{format}} 내보내기 스니펫을 클립보드에 복사",
"footer_tip": "이것을 GitHub 프로필 README.md 파일에 붙여넣으세요. 배지는 서버 측에서 렌더링되므로 스크립트가 필요하지 않습니다.",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_svg": "SVG 다운로드",
"download_png": "PNG 다운로드",
"download_webp": "WebP 다운로드",
"download_pdf": "PDF 다운로드",
"export_as": "다른 형식으로 내보내기"
}
},
"dashboard": {
Expand Down
5 changes: 4 additions & 1 deletion locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@
"footer_tip": "Cole isso no README.md do perfil do GitHub. O emblema é renderizado no lado do servidor, sem necessidade de script.",
"tsx": "React TSX",
"download_svg": "Baixar SVG",
"download_png": "Baixar PNG"
"download_png": "Baixar PNG",
"download_webp": "Baixar WebP",
"download_pdf": "Baixar PDF",
"export_as": "Exportar como"
}
},
"dashboard": {
Expand Down
7 changes: 5 additions & 2 deletions locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@
"copy_aria_enabled": "将 {{format}} 导出片段复制到剪贴板",
"footer_tip": "将此片段粘贴到您的 GitHub 个人主页 README.md 文件中。徽章由服务器端渲染,无需任何脚本。",
"tsx": "React TSX",
"download_svg": "Download SVG",
"download_png": "Download PNG"
"download_svg": "下载 SVG",
"download_png": "下载 PNG",
"download_webp": "下载 WebP",
"download_pdf": "下载 PDF",
"export_as": "导出为"
}
},
"dashboard": {
Expand Down
Loading
Loading