Skip to content

Commit a103aef

Browse files
feat: add multi-format social exports without zip
Generate platform-specific images from one fixed certificate render (Instagram portrait/square, X, Facebook, Story) and expose separate one-click download actions. Made-with: Cursor
1 parent 8c895c3 commit a103aef

1 file changed

Lines changed: 152 additions & 3 deletions

File tree

src/components/CertificateCard.tsx

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
1424
function 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

Comments
 (0)