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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ export default function QASection() {
|
|||||||
setPage(0)
|
setPage(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveAsImage = (question: DecoratedQuestion) => {
|
const buildQuoteCanvas = (question: DecoratedQuestion): HTMLCanvasElement => {
|
||||||
const SIZE = 1080
|
const SIZE = 1080
|
||||||
const PADDING = 72
|
const PADDING = 72
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -603,15 +603,12 @@ export default function QASection() {
|
|||||||
canvas.height = SIZE
|
canvas.height = SIZE
|
||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = '#1a1208'
|
ctx.fillStyle = '#1a1208'
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE)
|
ctx.fillRect(0, 0, SIZE, SIZE)
|
||||||
|
|
||||||
// Gold top border
|
|
||||||
ctx.fillStyle = '#c9a84c'
|
ctx.fillStyle = '#c9a84c'
|
||||||
ctx.fillRect(0, 0, SIZE, 6)
|
ctx.fillRect(0, 0, SIZE, 6)
|
||||||
|
|
||||||
// Helper: word-wrap text
|
|
||||||
function wrapText(text: string, maxWidth: number, font: string): string[] {
|
function wrapText(text: string, maxWidth: number, font: string): string[] {
|
||||||
ctx.font = font
|
ctx.font = font
|
||||||
const words = text.split(' ')
|
const words = text.split(' ')
|
||||||
@@ -619,12 +616,8 @@ export default function QASection() {
|
|||||||
let line = ''
|
let line = ''
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
const test = line ? `${line} ${word}` : word
|
const test = line ? `${line} ${word}` : word
|
||||||
if (ctx.measureText(test).width > maxWidth && line) {
|
if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word }
|
||||||
lines.push(line)
|
else { line = test }
|
||||||
line = word
|
|
||||||
} else {
|
|
||||||
line = test
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (line) lines.push(line)
|
if (line) lines.push(line)
|
||||||
return lines
|
return lines
|
||||||
@@ -632,51 +625,41 @@ export default function QASection() {
|
|||||||
|
|
||||||
let y = PADDING + 40
|
let y = PADDING + 40
|
||||||
|
|
||||||
// Ornament header
|
|
||||||
ctx.fillStyle = '#c9a84c'
|
ctx.fillStyle = '#c9a84c'
|
||||||
ctx.font = 'bold 26px Georgia, serif'
|
ctx.font = 'bold 26px Georgia, serif'
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y)
|
ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y)
|
||||||
y += 50
|
y += 50
|
||||||
|
|
||||||
// Divider
|
|
||||||
ctx.fillStyle = '#c9a84c'
|
ctx.fillStyle = '#c9a84c'
|
||||||
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
||||||
y += 36
|
y += 36
|
||||||
|
|
||||||
// Question label
|
|
||||||
ctx.fillStyle = '#c9a84c'
|
ctx.fillStyle = '#c9a84c'
|
||||||
ctx.font = 'bold 22px Georgia, serif'
|
ctx.font = 'bold 22px Georgia, serif'
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
ctx.fillText('Q', PADDING, y)
|
ctx.fillText('Q', PADDING, y)
|
||||||
y += 8
|
y += 8
|
||||||
|
|
||||||
// Question text
|
|
||||||
const qFont = 'italic 32px Georgia, serif'
|
const qFont = 'italic 32px Georgia, serif'
|
||||||
const qLines = wrapText(`"${question.question}"`, SIZE - PADDING * 2 - 20, qFont)
|
const qLines = wrapText(`"${question.question}"`, SIZE - PADDING * 2 - 20, qFont)
|
||||||
ctx.fillStyle = '#f5ead2'
|
ctx.fillStyle = '#f5ead2'
|
||||||
ctx.font = qFont
|
ctx.font = qFont
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
const qLineH = 44
|
const qLineH = 44
|
||||||
for (const line of qLines.slice(0, 5)) {
|
for (const line of qLines.slice(0, 5)) { y += qLineH; ctx.fillText(line, PADDING + 20, y) }
|
||||||
y += qLineH
|
|
||||||
ctx.fillText(line, PADDING + 20, y)
|
|
||||||
}
|
|
||||||
y += 40
|
y += 40
|
||||||
|
|
||||||
// Divider
|
|
||||||
ctx.fillStyle = '#3a2d14'
|
ctx.fillStyle = '#3a2d14'
|
||||||
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
||||||
y += 36
|
y += 36
|
||||||
|
|
||||||
// Answer label
|
|
||||||
ctx.fillStyle = '#c9a84c'
|
ctx.fillStyle = '#c9a84c'
|
||||||
ctx.font = 'bold 22px Georgia, serif'
|
ctx.font = 'bold 22px Georgia, serif'
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
ctx.fillText('A', PADDING, y)
|
ctx.fillText('A', PADDING, y)
|
||||||
y += 8
|
y += 8
|
||||||
|
|
||||||
// Answer text (truncated to fit)
|
|
||||||
const aFont = '28px Georgia, serif'
|
const aFont = '28px Georgia, serif'
|
||||||
const maxAHeight = SIZE - y - PADDING - 60
|
const maxAHeight = SIZE - y - PADDING - 60
|
||||||
const aLineH = 40
|
const aLineH = 40
|
||||||
@@ -688,24 +671,62 @@ export default function QASection() {
|
|||||||
}
|
}
|
||||||
ctx.fillStyle = '#e8d9b5'
|
ctx.fillStyle = '#e8d9b5'
|
||||||
ctx.font = aFont
|
ctx.font = aFont
|
||||||
for (const line of aLines) {
|
for (const line of aLines) { y += aLineH; ctx.fillText(line, PADDING + 20, y) }
|
||||||
y += aLineH
|
|
||||||
ctx.fillText(line, PADDING + 20, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL footer
|
|
||||||
ctx.fillStyle = '#8a7040'
|
ctx.fillStyle = '#8a7040'
|
||||||
ctx.font = '20px Georgia, serif'
|
ctx.font = '20px Georgia, serif'
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.fillText(window.location.host + '/questions', SIZE / 2, SIZE - PADDING + 8)
|
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')
|
const link = document.createElement('a')
|
||||||
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
|
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
|
||||||
link.href = canvas.toDataURL('image/png')
|
link.href = canvas.toDataURL('image/png')
|
||||||
link.click()
|
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<Blob>((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) =>
|
const getShareUrl = (id: string) =>
|
||||||
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
|
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
|
||||||
|
|
||||||
@@ -1041,7 +1062,15 @@ export default function QASection() {
|
|||||||
{linkCopied ? '✓ Copied' : 'Copy link'}
|
{linkCopied ? '✓ Copied' : 'Copy link'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="qa-share-btn" onClick={() => saveAsImage(question)}>
|
<button type="button" className="qa-share-btn" onClick={() => saveAsImage(question)}>
|
||||||
Save as image
|
Save image
|
||||||
|
</button>
|
||||||
|
<button type="button" className="qa-social-btn qa-social-btn--x" onClick={() => shareImageTo(question, 'x')}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||||
|
<span>Image</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="qa-social-btn qa-social-btn--fb" onClick={() => shareImageTo(question, 'facebook')}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M24 12.073C24 5.404 18.627 0 12 0S0 5.404 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.236 2.686.236v2.97h-1.513c-1.491 0-1.956.93-1.956 1.884v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/></svg>
|
||||||
|
<span>Image</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(facebookCaptionCopied || linkCopied) && (
|
{(facebookCaptionCopied || linkCopied) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user