@@ -11,6 +11,16 @@ interface Props {
1111 onReset : ( ) => void
1212}
1313
14+ const SOCIAL_BG = '#E8E8E8'
15+ const SOCIAL_EXPORT_FORMATS = {
16+ instagramPortrait : { width : 1080 , height : 1350 , padding : 64 , filename : 'instagram-portrait' } ,
17+ instagramSquare : { width : 1080 , height : 1080 , padding : 48 , filename : 'instagram-square' } ,
18+ xLandscape : { width : 1200 , height : 675 , padding : 40 , filename : 'x-landscape' } ,
19+ facebookFeed : { width : 1200 , height : 630 , padding : 40 , filename : 'facebook-feed' } ,
20+ story : { width : 1080 , height : 1920 , padding : 80 , filename : 'story' } ,
21+ } as const
22+ type SocialFormatKey = keyof typeof SOCIAL_EXPORT_FORMATS
23+
1424function buildShareCopy ( cert : DeathCertificate , shareUrl : string ) : string {
1525 const repo = cert . repoData . fullName
1626 const cause = cert . causeOfDeath
@@ -99,6 +109,36 @@ export default function CertificateCard({ cert, onReset }: Props) {
99109 }
100110 }
101111
112+ async function composeSocialBlob (
113+ masterBlob : Blob ,
114+ format : { width : number ; height : number ; padding : number }
115+ ) : Promise < Blob | null > {
116+ try {
117+ const img = await loadImageForCanvas ( masterBlob )
118+ const canvas = document . createElement ( 'canvas' )
119+ canvas . width = format . width
120+ canvas . height = format . height
121+ const ctx = canvas . getContext ( '2d' )
122+ if ( ! ctx ) return null
123+
124+ ctx . fillStyle = SOCIAL_BG
125+ ctx . fillRect ( 0 , 0 , canvas . width , canvas . height )
126+
127+ const availableWidth = canvas . width - format . padding * 2
128+ const availableHeight = canvas . height - format . padding * 2
129+ const scale = Math . min ( availableWidth / img . width , availableHeight / img . height )
130+ const drawWidth = img . width * scale
131+ const drawHeight = img . height * scale
132+ const x = ( canvas . width - drawWidth ) / 2
133+ const y = ( canvas . height - drawHeight ) / 2
134+
135+ ctx . drawImage ( img , x , y , drawWidth , drawHeight )
136+ return await new Promise ( resolve => canvas . toBlob ( b => resolve ( b ) , 'image/png' ) )
137+ } catch {
138+ return null
139+ }
140+ }
141+
102142 const stat = ( counter : string ) => fetch ( '/api/stats' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { counter } ) } ) . catch ( ( ) => { } )
103143
104144 function triggerDownload ( blob : Blob , filename : string ) {
@@ -117,7 +157,9 @@ export default function CertificateCard({ cert, onReset }: Props) {
117157 const shareText = buildShareCopy ( cert , shareUrl )
118158
119159 async function generateShareBlob ( ) {
120- return exportBlob ( 2.5 , true )
160+ const masterBlob = await exportBlob ( 2.5 , true )
161+ if ( ! masterBlob ) return null
162+ return composeSocialBlob ( masterBlob , SOCIAL_EXPORT_FORMATS . instagramPortrait )
121163 }
122164
123165 async function handleShare ( ) {
@@ -177,11 +219,22 @@ export default function CertificateCard({ cert, onReset }: Props) {
177219 async function handleDownloadShareImage ( ) {
178220 const blob = await generateShareBlob ( )
179221 if ( ! blob ) return
180- triggerDownload ( blob , `${ cert . repoData . name } -share .png` )
222+ triggerDownload ( blob , `${ cert . repoData . name } -${ SOCIAL_EXPORT_FORMATS . instagramPortrait . filename } .png` )
181223 stat ( 'downloaded' )
182224 setShowInlineShare ( false )
183225 }
184226
227+ async function handleDownloadFormat ( formatKey : SocialFormatKey ) {
228+ const masterBlob = await exportBlob ( 2.5 , true )
229+ if ( ! masterBlob ) return
230+ const format = SOCIAL_EXPORT_FORMATS [ formatKey ]
231+ const blob = await composeSocialBlob ( masterBlob , format )
232+ if ( ! blob ) return
233+ triggerDownload ( blob , `${ cert . repoData . name } -${ format . filename } .png` )
234+ stat ( 'downloaded' )
235+ }
236+
237+
185238 async function handleDownloadA4 ( ) {
186239 const blob = await exportBlob ( 3 , true )
187240 if ( ! blob ) return
@@ -375,7 +428,103 @@ export default function CertificateCard({ cert, onReset }: Props) {
375428 onMouseUp = { e => { e . currentTarget . style . opacity = '1' } }
376429 onMouseLeave = { e => { e . currentTarget . style . opacity = '1' } }
377430 >
378- Download image
431+ Download Instagram (4:5)
432+ </ button >
433+ < button
434+ type = "button"
435+ onClick = { ( ) => handleDownloadFormat ( 'instagramSquare' ) }
436+ style = { {
437+ fontFamily : UI ,
438+ fontSize : '14px' ,
439+ fontWeight : 600 ,
440+ width : '100%' ,
441+ height : '52px' ,
442+ background : '#fff' ,
443+ color : '#0a0a0a' ,
444+ border : '2px solid #0a0a0a' ,
445+ cursor : 'pointer' ,
446+ display : 'flex' ,
447+ alignItems : 'center' ,
448+ justifyContent : 'center' ,
449+ transition : 'opacity 0.12s' ,
450+ } }
451+ onMouseDown = { e => { e . currentTarget . style . opacity = '0.85' } }
452+ onMouseUp = { e => { e . currentTarget . style . opacity = '1' } }
453+ onMouseLeave = { e => { e . currentTarget . style . opacity = '1' } }
454+ >
455+ Download Square (1:1)
456+ </ button >
457+ < button
458+ type = "button"
459+ onClick = { ( ) => handleDownloadFormat ( 'xLandscape' ) }
460+ style = { {
461+ fontFamily : UI ,
462+ fontSize : '14px' ,
463+ fontWeight : 600 ,
464+ width : '100%' ,
465+ height : '52px' ,
466+ background : '#fff' ,
467+ color : '#0a0a0a' ,
468+ border : '2px solid #0a0a0a' ,
469+ cursor : 'pointer' ,
470+ display : 'flex' ,
471+ alignItems : 'center' ,
472+ justifyContent : 'center' ,
473+ transition : 'opacity 0.12s' ,
474+ } }
475+ onMouseDown = { e => { e . currentTarget . style . opacity = '0.85' } }
476+ onMouseUp = { e => { e . currentTarget . style . opacity = '1' } }
477+ onMouseLeave = { e => { e . currentTarget . style . opacity = '1' } }
478+ >
479+ Download X (16:9)
480+ </ button >
481+ < button
482+ type = "button"
483+ onClick = { ( ) => handleDownloadFormat ( 'facebookFeed' ) }
484+ style = { {
485+ fontFamily : UI ,
486+ fontSize : '14px' ,
487+ fontWeight : 600 ,
488+ width : '100%' ,
489+ height : '52px' ,
490+ background : '#fff' ,
491+ color : '#0a0a0a' ,
492+ border : '2px solid #0a0a0a' ,
493+ cursor : 'pointer' ,
494+ display : 'flex' ,
495+ alignItems : 'center' ,
496+ justifyContent : 'center' ,
497+ transition : 'opacity 0.12s' ,
498+ } }
499+ onMouseDown = { e => { e . currentTarget . style . opacity = '0.85' } }
500+ onMouseUp = { e => { e . currentTarget . style . opacity = '1' } }
501+ onMouseLeave = { e => { e . currentTarget . style . opacity = '1' } }
502+ >
503+ Download Facebook (1.91:1)
504+ </ button >
505+ < button
506+ type = "button"
507+ onClick = { ( ) => handleDownloadFormat ( 'story' ) }
508+ style = { {
509+ fontFamily : UI ,
510+ fontSize : '14px' ,
511+ fontWeight : 600 ,
512+ width : '100%' ,
513+ height : '52px' ,
514+ background : '#fff' ,
515+ color : '#0a0a0a' ,
516+ border : '2px solid #0a0a0a' ,
517+ cursor : 'pointer' ,
518+ display : 'flex' ,
519+ alignItems : 'center' ,
520+ justifyContent : 'center' ,
521+ transition : 'opacity 0.12s' ,
522+ } }
523+ onMouseDown = { e => { e . currentTarget . style . opacity = '0.85' } }
524+ onMouseUp = { e => { e . currentTarget . style . opacity = '1' } }
525+ onMouseLeave = { e => { e . currentTarget . style . opacity = '1' } }
526+ >
527+ Download Story (9:16)
379528 </ button >
380529 < button
381530 type = "button"
0 commit comments