@@ -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 ( / & a m p ; / 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+
1833export 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 ( / & a m p ; / 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 ( / \( ( h t t p s ? : \/ \/ [ ^ ) ] + ) \) / ) || snippet . match ( / s r c = " ( [ ^ " ] + ) " / ) ;
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 ( / & a m p ; / 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,11 +262,88 @@ 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 ( / \( ( h t t p s ? : \/ \/ [ ^ ) ] + ) \) / ) || snippet . match ( / s r c = " ( [ ^ " ] + ) " / ) ;
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 */ }
248345 < div className = "flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between" >
249- < div className = "flex items-center gap-3" >
346+ < div className = "flex flex-wrap items-center gap-3" >
250347 < div
251348 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"
252349 aria-label = "Export format"
@@ -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"
0 commit comments