Import source: app/src/components/QuoteSharer

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:24:29 +03:30
parent b3b1d69671
commit 4b54e9a387
+335
View File
@@ -0,0 +1,335 @@
// QuoteSharer — renders a floating "share quote" tooltip on text selection inside the article.
// Uses Canvas API for branded card rendering; Web Share API or download as PNG fallback.
import { useEffect, useRef, useState, useCallback } from 'react';
import { contentApi } from '../lib/content-api.js';
import shareIcon from '../assets/icons/share-2.svg';
import clipboardIcon from '../assets/icons/clipboard-copy.svg';
const BRAND_BG1 = '#0C1E22';
const BRAND_BG2 = '#0F6B73';
const BRAND_GOLD = '#D49A2A';
const FONT = '\'Vazirmatn\', Tahoma, sans-serif';
function loadFont() {
if (typeof document === 'undefined') return Promise.resolve();
return document.fonts.load(`700 18px Vazirmatn`).catch(() => {});
}
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
const words = text.split(' ').filter(Boolean);
let line = '';
let currentY = y;
const lines = [];
for (const word of words) {
const test = line ? `${line} ${word}` : word;
const m = ctx.measureText(test);
if (m.width > maxWidth && line) {
lines.push(line);
line = word;
} else {
line = test;
}
}
if (line) lines.push(line);
for (const l of lines.slice(0, 4)) {
ctx.fillText(l, x, currentY);
currentY += lineHeight;
}
return currentY;
}
function renderCard(canvas, text, articleTitle, authorName) {
const W = 1080, H = 1080;
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
// Background gradient
const grad = ctx.createLinearGradient(0, 0, W, H);
grad.addColorStop(0, BRAND_BG1);
grad.addColorStop(1, '#0F3540');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// Decorative corner accent
ctx.fillStyle = BRAND_BG2;
ctx.beginPath();
ctx.moveTo(W, 0);
ctx.lineTo(W, 220);
ctx.lineTo(W - 220, 0);
ctx.closePath();
ctx.fill();
// Subtle grid pattern overlay
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
for (let i = 0; i < W; i += 60) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(W, i); ctx.stroke();
}
// Gold separator
ctx.fillStyle = BRAND_GOLD;
ctx.fillRect(80, 90, 5, 80);
// TEKNAV logo text
ctx.fillStyle = BRAND_GOLD;
ctx.font = `700 32px ${FONT}`;
ctx.textAlign = 'right';
ctx.direction = 'rtl';
ctx.fillText('تکناو', W - 60, 80);
// Tagline next to logo
ctx.fillStyle = 'rgba(212,154,42,0.55)';
ctx.font = `400 16px ${FONT}`;
ctx.fillText('teknav.ir', W - 60, 106);
// Quote marks
ctx.fillStyle = 'rgba(212,154,42,0.18)';
ctx.font = `700 280px ${FONT}`;
ctx.textAlign = 'right';
ctx.fillText('«', W - 40, 420);
// Quote text
ctx.fillStyle = '#FFFFFF';
ctx.font = `400 38px ${FONT}`;
ctx.textAlign = 'right';
ctx.direction = 'rtl';
const nextY = wrapText(ctx, text, W - 80, 220, W - 160, 60);
// Gold line
ctx.fillStyle = BRAND_GOLD;
ctx.fillRect(80, nextY + 20, W - 160, 2);
// Article title
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.font = `400 26px ${FONT}`;
ctx.textAlign = 'right';
const titleTrunc = articleTitle.length > 60 ? `${articleTitle.slice(0, 59)}…` : articleTitle;
ctx.fillText(titleTrunc, W - 80, nextY + 70);
// Author
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = `400 22px ${FONT}`;
ctx.fillText(authorName, W - 80, H - 60);
}
export function QuoteSharer({ containerRef, article }) {
const [tooltip, setTooltip] = useState(null); // { x, y, text }
const [modal, setModal] = useState(false);
const [sharing, setSharing] = useState(false);
const [copied, setCopied] = useState(false);
const canvasRef = useRef(null);
const fontReady = useRef(false);
useEffect(() => { loadFont().then(() => { fontReady.current = true; }); }, []);
// Close modal on Escape
useEffect(() => {
if (!modal) return;
const onKey = (e) => { if (e.key === 'Escape') { setModal(false); setTooltip(null); } };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [modal]);
const handleSelection = useCallback(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
setTooltip(null);
return;
}
const container = containerRef?.current;
if (!container) return;
let node = sel.anchorNode;
while (node && node !== container) node = node.parentNode;
if (!node) { setTooltip(null); return; }
const text = sel.toString().trim();
if (text.length < 20 || text.length > 400) { setTooltip(null); return; }
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
setTooltip({
x: rect.left + rect.width / 2,
y: rect.top + window.scrollY - 48,
text,
});
}, [containerRef]);
useEffect(() => {
document.addEventListener('mouseup', handleSelection);
document.addEventListener('touchend', handleSelection);
return () => {
document.removeEventListener('mouseup', handleSelection);
document.removeEventListener('touchend', handleSelection);
};
}, [handleSelection]);
const openModal = useCallback(() => {
if (!tooltip) return;
setModal(true);
try {
contentApi.trackEvent({ type: 'share_clicked', metadata: { channel: 'quote_card', slug: article?.slug } });
} catch {}
requestAnimationFrame(() => {
if (canvasRef.current) {
renderCard(
canvasRef.current,
tooltip.text,
article?.title || 'تکناو',
article?.authorName || 'تکناو',
);
}
});
}, [tooltip, article]);
const download = useCallback(() => {
if (!canvasRef.current) return;
canvasRef.current.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'teknav-quote.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');
}, []);
const copyText = useCallback(() => {
if (!tooltip?.text) return;
const fullText = `«${tooltip.text}»\n\n— از مقاله: ${article?.title || 'تکناو'}\nhttps://www.teknav.ir/article/${article?.slug || ''}`;
try {
navigator.clipboard.writeText(fullText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => {});
} catch {}
}, [tooltip, article]);
const share = useCallback(async () => {
if (!canvasRef.current || sharing) return;
setSharing(true);
try {
if (navigator.share && navigator.canShare) {
const blob = await new Promise((res) => canvasRef.current.toBlob(res, 'image/png'));
const file = new File([blob], 'teknav-quote.png', { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: article?.title || 'تکناو',
url: `https://www.teknav.ir/article/${article?.slug || ''}`,
});
setSharing(false);
return;
}
}
download();
} catch {}
setSharing(false);
}, [sharing, download, article]);
if (!tooltip && !modal) return null;
return (
<>
{tooltip && !modal && (
<div
style={{
position: 'absolute',
left: tooltip.x,
top: tooltip.y,
transform: 'translateX(-50%)',
zIndex: 1000,
background: '#0F3540',
border: '1px solid #D49A2A',
borderRadius: 8,
padding: '6px 14px',
cursor: 'pointer',
color: '#fff',
fontSize: 13,
fontFamily: FONT,
whiteSpace: 'nowrap',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
onMouseDown={(e) => { e.preventDefault(); openModal(); }}
>
<span className="tek-icon" style={{ ['--i']: `url("${shareIcon}")` }} aria-hidden="true" />
<span>اشتراک‌گذاری نقل‌قول</span>
</div>
)}
{modal && (
<div
role="dialog"
aria-modal="true"
aria-label="اشتراک‌گذاری نقل‌قول"
style={{
position: 'fixed', inset: 0, zIndex: 2000,
background: 'rgba(0,0,0,0.85)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 16,
fontFamily: FONT, direction: 'rtl',
}}
onClick={(e) => { if (e.target === e.currentTarget) { setModal(false); setTooltip(null); } }}
>
<canvas
ref={canvasRef}
style={{ maxWidth: 'min(90vw, 480px)', maxHeight: '65vh', borderRadius: 12, border: '2px solid #D49A2A' }}
/>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'center' }}>
<button
onClick={share}
disabled={sharing}
style={{
background: '#0F6B73', color: '#fff', border: 'none',
borderRadius: 8, padding: '10px 22px', fontSize: 14,
fontFamily: FONT, cursor: 'pointer', fontWeight: 700,
}}
>
{sharing ? 'در حال اشتراک…' : <><span className="tek-icon" style={{ ['--i']: `url("${shareIcon}")` }} aria-hidden="true" /> اشتراک‌گذاری</>}
</button>
<button
onClick={copyText}
style={{
background: copied ? '#2E7D32' : '#1A3A42', color: copied ? '#fff' : '#D49A2A',
border: `1px solid ${copied ? '#2E7D32' : '#D49A2A'}`,
borderRadius: 8, padding: '10px 22px', fontSize: 14,
fontFamily: FONT, cursor: 'pointer', fontWeight: 700,
transition: 'all 0.2s',
}}
>
{copied ? '✓ کپی شد' : <><span className="tek-icon" style={{ ['--i']: `url("${clipboardIcon}")` }} aria-hidden="true" /> کپی متن</>}
</button>
<button
onClick={download}
style={{
background: '#1A3A42', color: '#D49A2A', border: '1px solid #D49A2A',
borderRadius: 8, padding: '10px 22px', fontSize: 14,
fontFamily: FONT, cursor: 'pointer', fontWeight: 700,
}}
>
⬇ PNG
</button>
<button
onClick={() => { setModal(false); setTooltip(null); }}
style={{
background: 'transparent', color: 'rgba(255,255,255,0.6)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: 8, padding: '10px 16px', fontSize: 14,
fontFamily: FONT, cursor: 'pointer',
}}
>
بستن
</button>
</div>
<p style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12, margin: 0 }}>Esc برای بستن</p>
</div>
)}
</>
);
}