From ff7eec99061f05c0f5141465e99fde726d4eecb1 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 22 Jul 2026 12:57:13 -0400 Subject: [PATCH] Add share-as-image to X and Facebook for Q&A cards; v1.0.12 On mobile: Web Share API opens native share sheet with the PNG attached, letting users pick X, Facebook, Instagram, etc. directly. On desktop: downloads the image and opens the platform compose window. "Save image" download button kept alongside the two new share buttons. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/components/QASection.tsx | 85 ++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index b7dd235..45c8722 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.0.11", + "version": "1.0.12", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/QASection.tsx b/src/components/QASection.tsx index dc4ba46..21fdb75 100644 --- a/src/components/QASection.tsx +++ b/src/components/QASection.tsx @@ -595,7 +595,7 @@ export default function QASection() { setPage(0) } - const saveAsImage = (question: DecoratedQuestion) => { + const buildQuoteCanvas = (question: DecoratedQuestion): HTMLCanvasElement => { const SIZE = 1080 const PADDING = 72 const canvas = document.createElement('canvas') @@ -603,15 +603,12 @@ export default function QASection() { canvas.height = SIZE const ctx = canvas.getContext('2d')! - // Background ctx.fillStyle = '#1a1208' ctx.fillRect(0, 0, SIZE, SIZE) - // Gold top border ctx.fillStyle = '#c9a84c' ctx.fillRect(0, 0, SIZE, 6) - // Helper: word-wrap text function wrapText(text: string, maxWidth: number, font: string): string[] { ctx.font = font const words = text.split(' ') @@ -619,12 +616,8 @@ export default function QASection() { let line = '' for (const word of words) { const test = line ? `${line} ${word}` : word - if (ctx.measureText(test).width > maxWidth && line) { - lines.push(line) - line = word - } else { - line = test - } + if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word } + else { line = test } } if (line) lines.push(line) return lines @@ -632,51 +625,41 @@ export default function QASection() { let y = PADDING + 40 - // Ornament header ctx.fillStyle = '#c9a84c' ctx.font = 'bold 26px Georgia, serif' ctx.textAlign = 'center' ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y) y += 50 - // Divider ctx.fillStyle = '#c9a84c' ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1) y += 36 - // Question label ctx.fillStyle = '#c9a84c' ctx.font = 'bold 22px Georgia, serif' ctx.textAlign = 'left' ctx.fillText('Q', PADDING, y) y += 8 - // Question text const qFont = 'italic 32px Georgia, serif' const qLines = wrapText(`"${question.question}"`, SIZE - PADDING * 2 - 20, qFont) ctx.fillStyle = '#f5ead2' ctx.font = qFont ctx.textAlign = 'left' const qLineH = 44 - for (const line of qLines.slice(0, 5)) { - y += qLineH - ctx.fillText(line, PADDING + 20, y) - } + for (const line of qLines.slice(0, 5)) { y += qLineH; ctx.fillText(line, PADDING + 20, y) } y += 40 - // Divider ctx.fillStyle = '#3a2d14' ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1) y += 36 - // Answer label ctx.fillStyle = '#c9a84c' ctx.font = 'bold 22px Georgia, serif' ctx.textAlign = 'left' ctx.fillText('A', PADDING, y) y += 8 - // Answer text (truncated to fit) const aFont = '28px Georgia, serif' const maxAHeight = SIZE - y - PADDING - 60 const aLineH = 40 @@ -688,24 +671,62 @@ export default function QASection() { } ctx.fillStyle = '#e8d9b5' ctx.font = aFont - for (const line of aLines) { - y += aLineH - ctx.fillText(line, PADDING + 20, y) - } + for (const line of aLines) { y += aLineH; ctx.fillText(line, PADDING + 20, y) } - // URL footer ctx.fillStyle = '#8a7040' ctx.font = '20px Georgia, serif' ctx.textAlign = 'center' ctx.fillText(window.location.host + '/questions', SIZE / 2, SIZE - PADDING + 8) - // Download + return canvas + } + + const saveAsImage = (question: DecoratedQuestion) => { + const canvas = buildQuoteCanvas(question) const link = document.createElement('a') link.download = `vbvn-qa-${question.id.slice(0, 8)}.png` link.href = canvas.toDataURL('image/png') link.click() } + const shareImageTo = async (question: DecoratedQuestion, platform: 'x' | 'facebook') => { + const canvas = buildQuoteCanvas(question) + const shareUrl = getSocialUrl(question.id) + const shareText = `Q: ${question.question.slice(0, 120)}${question.question.length > 120 ? '…' : ''}` + + // Try Web Share API with file (works on mobile — iOS Safari, Android Chrome) + if (typeof navigator.share === 'function') { + try { + const blob = await new Promise((resolve, reject) => + canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas toBlob failed')), 'image/png') + ) + const file = new File([blob], `vbvn-qa-${question.id.slice(0, 8)}.png`, { type: 'image/png' }) + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file], text: shareText, url: shareUrl }) + incrementEngagement(question.id, 'shares') + return + } + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') return + } + } + + // Desktop fallback: download the image, then open the compose window + const link = document.createElement('a') + link.download = `vbvn-qa-${question.id.slice(0, 8)}.png` + link.href = canvas.toDataURL('image/png') + link.click() + + if (platform === 'x') { + const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}` + window.open(tweetUrl, '_blank', 'width=600,height=400,noopener') + } else { + const fbUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}` + window.open(fbUrl, '_blank', 'width=600,height=400,noopener') + } + incrementEngagement(question.id, 'shares') + } + const getShareUrl = (id: string) => `${window.location.origin}/questions#qa-${encodeURIComponent(id)}` @@ -1041,7 +1062,15 @@ export default function QASection() { {linkCopied ? '✓ Copied' : 'Copy link'} + + {(facebookCaptionCopied || linkCopied) && (