chore: make able to switch screenshots in zoom
This commit is contained in:
parent
0079da3b49
commit
5b99a76745
40
package-lock.json
generated
40
package-lock.json
generated
@ -9,18 +9,18 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.3",
|
||||
"axios": "^1.14.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.7",
|
||||
"axios": "^1.15.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"or": "^0.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-is": "^19.2.4",
|
||||
"react-is": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1",
|
||||
@ -28,7 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@ -37,8 +37,8 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@ -1042,13 +1042,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
|
||||
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@ -1978,9 +1978,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.334",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
|
||||
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
|
||||
"version": "1.5.335",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz",
|
||||
"integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -4829,9 +4829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
22
package.json
22
package.json
@ -12,18 +12,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.3",
|
||||
"axios": "^1.14.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.7",
|
||||
"axios": "^1.15.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"or": "^0.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-is": "^19.2.4",
|
||||
"react-is": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1",
|
||||
@ -31,7 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@ -40,10 +40,10 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^8.0.4"
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,27 +9,27 @@ import SEO from '../components/SEO';
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'Malware or malicious code',
|
||||
'Stolen or plagiarised content',
|
||||
'Misleading description',
|
||||
'Broken / non-functional',
|
||||
'Inappropriate content',
|
||||
'Spam or fake listing',
|
||||
'Other',
|
||||
'Stolen or plagiarised content',
|
||||
'Misleading description',
|
||||
'Broken / non-functional',
|
||||
'Inappropriate content',
|
||||
'Spam or fake listing',
|
||||
'Other',
|
||||
];
|
||||
|
||||
const MAX_REVIEW_LENGTH = 2000;
|
||||
|
||||
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
|
||||
<div className="star-rating-container">
|
||||
{[...Array(max)].map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
|
||||
onClick={() => onRate && onRate(i + 1)}
|
||||
>
|
||||
{i < rating ? '★' : '☆'}
|
||||
</span>
|
||||
))}
|
||||
{[...Array(max)].map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
|
||||
onClick={() => onRate && onRate(i + 1)}
|
||||
>
|
||||
{i < rating ? '★' : '☆'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -39,10 +39,10 @@ const PriceDisplay = ({ cents }: { cents: number }) => {
|
||||
const remainder = (cents % 100).toString().padStart(2, '0');
|
||||
return (
|
||||
<div className="price-container">
|
||||
<span className="currency-symbol">€</span>
|
||||
<span className="price-dollars">{euros}</span>
|
||||
<span className="price-decimal">.</span>
|
||||
<span className="price-cents">{remainder}</span>
|
||||
<span className="currency-symbol">€</span>
|
||||
<span className="price-dollars">{euros}</span>
|
||||
<span className="price-decimal">.</span>
|
||||
<span className="price-cents">{remainder}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -73,6 +73,7 @@ const PluginDetail = () => {
|
||||
const [adShown, setAdShown] = useState(false);
|
||||
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
|
||||
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number>(0);
|
||||
const [selectedLanguage] = useState('en');
|
||||
const [currentDescription, setCurrentDescription] = useState('');
|
||||
const [setLoading] = useState(true);
|
||||
@ -117,9 +118,9 @@ const PluginDetail = () => {
|
||||
useEffect(() => {
|
||||
if (!user || !id) { setOwnershipChecked(true); return; }
|
||||
api.get(`/plugins/${id}/owned`)
|
||||
.then(res => setOwnsPlugin(res.data.owned === true))
|
||||
.catch(() => setOwnsPlugin(false))
|
||||
.finally(() => setOwnershipChecked(true));
|
||||
.then(res => setOwnsPlugin(res.data.owned === true))
|
||||
.catch(() => setOwnsPlugin(false))
|
||||
.finally(() => setOwnershipChecked(true));
|
||||
}, [user, id]);
|
||||
|
||||
// ── Translations ─────────────────────────────────────────────────────────
|
||||
@ -141,6 +142,42 @@ const PluginDetail = () => {
|
||||
}
|
||||
}, [plugin, selectedLanguage]);
|
||||
|
||||
// ── Lightbox helpers ─────────────────────────────────────────────────────
|
||||
const screenshots = plugin?.screenshots ?? [];
|
||||
|
||||
const openLightbox = (index: number) => {
|
||||
setLightboxIndex(index);
|
||||
setLightboxImage(screenshots[index]?.path ?? null);
|
||||
};
|
||||
|
||||
const lightboxPrev = () => {
|
||||
if (!screenshots.length) return;
|
||||
const newIndex = (lightboxIndex - 1 + screenshots.length) % screenshots.length;
|
||||
setLightboxIndex(newIndex);
|
||||
setLightboxImage(screenshots[newIndex].path);
|
||||
};
|
||||
|
||||
const lightboxNext = () => {
|
||||
if (!screenshots.length) return;
|
||||
const newIndex = (lightboxIndex + 1) % screenshots.length;
|
||||
setLightboxIndex(newIndex);
|
||||
setLightboxImage(screenshots[newIndex].path);
|
||||
};
|
||||
|
||||
const closeLightbox = () => setLightboxImage(null);
|
||||
|
||||
// ── Keyboard navigation for lightbox ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!lightboxImage) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') lightboxPrev();
|
||||
else if (e.key === 'ArrowRight') lightboxNext();
|
||||
else if (e.key === 'Escape') closeLightbox();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [lightboxImage, lightboxIndex, screenshots]);
|
||||
|
||||
const hasReviewed = useMemo(() => {
|
||||
if (!user || !plugin?.reviews || !Array.isArray(plugin.reviews)) return false;
|
||||
return plugin.reviews.some((r: any) => r.username === user.username);
|
||||
@ -265,14 +302,14 @@ const PluginDetail = () => {
|
||||
const downloadLabel = (() => {
|
||||
const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : '';
|
||||
|
||||
if (plugin.type === 'paid') {
|
||||
if (!ownershipChecked) return 'Loading…';
|
||||
return ownsPlugin ? `Download ${sizeStr}` : 'Purchase Plugin';
|
||||
}
|
||||
if (plugin.type === 'paid') {
|
||||
if (!ownershipChecked) return 'Loading…';
|
||||
return ownsPlugin ? `Download ${sizeStr}` : 'Purchase Plugin';
|
||||
}
|
||||
|
||||
if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download';
|
||||
if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download';
|
||||
|
||||
return `Download ${sizeStr}`;
|
||||
return `Download ${sizeStr}`;
|
||||
})();
|
||||
|
||||
const productSchema = {
|
||||
@ -299,332 +336,370 @@ const PluginDetail = () => {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<SEO
|
||||
title={plugin.name}
|
||||
description={currentDescription.substring(0, 160)}
|
||||
ogType="product"
|
||||
ogImage={plugin.preview_path || "/icon.png"}
|
||||
<SEO
|
||||
title={plugin.name}
|
||||
description={currentDescription.substring(0, 160)}
|
||||
ogType="product"
|
||||
ogImage={plugin.preview_path || "/icon.png"}
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(productSchema)}
|
||||
</script>
|
||||
|
||||
{/* ── Payment success banner ── */}
|
||||
{showPaymentSuccess && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
background: 'rgba(74,222,128,0.1)', border: '1px solid rgba(74,222,128,0.35)',
|
||||
borderRadius: 10, padding: '1rem 1.25rem', marginBottom: '1.5rem',
|
||||
}}>
|
||||
<span style={{ fontSize: '1.4rem' }}>🎉</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--success, #4ade80)', marginBottom: '0.15rem' }}>
|
||||
Purchase successful!
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
You now own <strong>{plugin.name}</strong>. Click the download button to get your WASM file.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPaymentSuccess(false)}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: 1 }}
|
||||
>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="plugin-detail-header">
|
||||
<div
|
||||
className="plugin-icon large"
|
||||
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
|
||||
>
|
||||
{!plugin.preview_path && plugin.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="plugin-title">{plugin.name}</h1>
|
||||
<p className="dev-name">by {plugin.dev_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-detail-layout">
|
||||
<div className="plugin-main-content">
|
||||
|
||||
{/* SCREENSHOTS */}
|
||||
{Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && (
|
||||
<div className="screenshot-gallery">
|
||||
<img
|
||||
src={mainScreenshot || plugin.screenshots[0].path}
|
||||
alt="Main"
|
||||
className="main-screenshot"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0].path));
|
||||
openLightbox(idx >= 0 ? idx : 0);
|
||||
}}
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(productSchema)}
|
||||
</script>
|
||||
<div className="thumbnail-strip">
|
||||
{plugin.screenshots.map((shot: any) => (
|
||||
<img
|
||||
key={shot.id}
|
||||
src={shot.path}
|
||||
alt="Thumbnail"
|
||||
className={`thumbnail ${mainScreenshot === shot.path ? 'active' : ''}`}
|
||||
onClick={() => setMainScreenshot(shot.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Payment success banner ── */}
|
||||
{showPaymentSuccess && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
background: 'rgba(74,222,128,0.1)', border: '1px solid rgba(74,222,128,0.35)',
|
||||
borderRadius: 10, padding: '1rem 1.25rem', marginBottom: '1.5rem',
|
||||
}}>
|
||||
<span style={{ fontSize: '1.4rem' }}>🎉</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--success, #4ade80)', marginBottom: '0.15rem' }}>
|
||||
Purchase successful!
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
You now own <strong>{plugin.name}</strong>. Click the download button to get your WASM file.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPaymentSuccess(false)}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: 1 }}
|
||||
>×</button>
|
||||
</div>
|
||||
)}
|
||||
{/* DESCRIPTION */}
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="plugin-detail-header">
|
||||
<hr className="divider" />
|
||||
|
||||
{/* REVIEWS */}
|
||||
<section className="reviews-section">
|
||||
<span className="reviews-section-label">Ratings & Reviews</span>
|
||||
|
||||
{stats ? (
|
||||
<div className="review-summary-grid">
|
||||
<div className="review-avg-block">
|
||||
<div className="review-big-avg">{stats.avg}</div>
|
||||
<div className="review-stars-row">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<span key={n} className={`review-star-icon ${n <= Math.round(Number(stats.avg)) ? 'filled' : 'empty'}`}>★</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="review-total-label">{stats.total.toLocaleString()} Ratings</div>
|
||||
</div>
|
||||
<div className="review-bars-block">
|
||||
{[5, 4, 3, 2, 1].map(num => (
|
||||
<div key={num} className="review-bar-row">
|
||||
<span className="review-bar-num">{num}</span>
|
||||
<div className="review-bar-track">
|
||||
<div
|
||||
className="plugin-icon large"
|
||||
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
|
||||
>
|
||||
{!plugin.preview_path && plugin.name.charAt(0)}
|
||||
className="review-bar-fill"
|
||||
style={{ width: `${(stats.distribution[num as keyof typeof stats.distribution] / stats.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="review-bar-count">
|
||||
{stats.distribution[num as keyof typeof stats.distribution]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-reviews-prompt">No reviews yet. Be the first to leave one!</div>
|
||||
)}
|
||||
|
||||
<div className="review-list-premium">
|
||||
{Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => (
|
||||
<div key={r.id} className="review-item-premium">
|
||||
<div className="review-item-top">
|
||||
<div className={`review-avatar ${getAvatarClass(r.username)}`}>
|
||||
{getInitials(r.username)}
|
||||
</div>
|
||||
<div className="review-item-meta">
|
||||
<span className="review-item-author">{r.username}</span>
|
||||
<span className="review-item-date">Verified User</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-item-stars">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<span key={n} className={`review-star-sm ${n <= r.rating ? 'filled' : 'empty'}`}>★</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="review-item-body">{r.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
hasReviewed ? (
|
||||
<div className="review-already-posted">
|
||||
<span className="review-already-icon">✓</span>
|
||||
<div>
|
||||
<h1 className="plugin-title">{plugin.name}</h1>
|
||||
<p className="dev-name">by {plugin.dev_name}</p>
|
||||
<div className="review-already-title">Review posted</div>
|
||||
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-detail-layout">
|
||||
<div className="plugin-main-content">
|
||||
|
||||
{/* SCREENSHOTS */}
|
||||
{Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && (
|
||||
<div className="screenshot-gallery">
|
||||
<img
|
||||
src={mainScreenshot || plugin.screenshots[0].path}
|
||||
alt="Main"
|
||||
className="main-screenshot"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setLightboxImage(mainScreenshot || plugin.screenshots[0].path)}
|
||||
/>
|
||||
<div className="thumbnail-strip">
|
||||
{plugin.screenshots.map((shot: any) => (
|
||||
<img
|
||||
key={shot.id}
|
||||
src={shot.path}
|
||||
alt="Thumbnail"
|
||||
className={`thumbnail ${mainScreenshot === shot.path ? 'active' : ''}`}
|
||||
onClick={() => setMainScreenshot(shot.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<hr className="divider" />
|
||||
|
||||
{/* REVIEWS */}
|
||||
<section className="reviews-section">
|
||||
<span className="reviews-section-label">Ratings & Reviews</span>
|
||||
|
||||
{stats ? (
|
||||
<div className="review-summary-grid">
|
||||
<div className="review-avg-block">
|
||||
<div className="review-big-avg">{stats.avg}</div>
|
||||
<div className="review-stars-row">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<span key={n} className={`review-star-icon ${n <= Math.round(Number(stats.avg)) ? 'filled' : 'empty'}`}>★</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="review-total-label">{stats.total.toLocaleString()} Ratings</div>
|
||||
</div>
|
||||
<div className="review-bars-block">
|
||||
{[5, 4, 3, 2, 1].map(num => (
|
||||
<div key={num} className="review-bar-row">
|
||||
<span className="review-bar-num">{num}</span>
|
||||
<div className="review-bar-track">
|
||||
<div
|
||||
className="review-bar-fill"
|
||||
style={{ width: `${(stats.distribution[num as keyof typeof stats.distribution] / stats.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="review-bar-count">
|
||||
{stats.distribution[num as keyof typeof stats.distribution]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-reviews-prompt">No reviews yet. Be the first to leave one!</div>
|
||||
)}
|
||||
|
||||
<div className="review-list-premium">
|
||||
{Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => (
|
||||
<div key={r.id} className="review-item-premium">
|
||||
<div className="review-item-top">
|
||||
<div className={`review-avatar ${getAvatarClass(r.username)}`}>
|
||||
{getInitials(r.username)}
|
||||
</div>
|
||||
<div className="review-item-meta">
|
||||
<span className="review-item-author">{r.username}</span>
|
||||
<span className="review-item-date">Verified User</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-item-stars">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<span key={n} className={`review-star-sm ${n <= r.rating ? 'filled' : 'empty'}`}>★</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="review-item-body">{r.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
hasReviewed ? (
|
||||
<div className="review-already-posted">
|
||||
<span className="review-already-icon">✓</span>
|
||||
<div>
|
||||
<div className="review-already-title">Review posted</div>
|
||||
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submitReview} className="review-submit-form">
|
||||
<h3 className="review-form-title">Leave a Review</h3>
|
||||
<div className="form-group">
|
||||
<label>Your Rating</label>
|
||||
<StarRating rating={reviewForm.rating} onRate={val => setReviewForm({ ...reviewForm, rating: val })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Your Experience</label>
|
||||
<textarea
|
||||
placeholder="What did you like? What could be better?"
|
||||
value={reviewForm.comment}
|
||||
maxLength={MAX_REVIEW_LENGTH}
|
||||
onChange={e => setReviewForm({ ...reviewForm, comment: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit">Post Review</button>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<div className="auth-notice">
|
||||
Please <Link to="/login">login</Link> to rate this plugin.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* SIDEBAR */}
|
||||
<aside className="plugin-sidebar">
|
||||
<div className="sidebar-widget">
|
||||
|
||||
{/* Price / owned indicator */}
|
||||
<div className="price-hero-section">
|
||||
{plugin.type === 'paid' && ownsPlugin ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
Purchased
|
||||
</div>
|
||||
) : plugin.type === 'paid' ? (
|
||||
<div>
|
||||
{plugin.sale_active && plugin.sale_discount_percent > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||||
{/* Sale price prominent */}
|
||||
<PriceDisplay cents={Math.round(plugin.price_cents * (1 - plugin.sale_discount_percent / 100))} />
|
||||
{/* Original price struck through */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
|
||||
€{(plugin.price_cents / 100).toFixed(2)}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.15)', border: '1px solid rgba(255,107,107,0.35)', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px', letterSpacing: '0.04em' }}>
|
||||
-{plugin.sale_discount_percent}% SALE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PriceDisplay cents={plugin.price_cents} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="price-free">{plugin.type === 'adwall' ? 'AD SUPPORTED' : 'FREE'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-download-main"
|
||||
onClick={handleDownload}
|
||||
disabled={plugin.type === 'paid' && !ownershipChecked}
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
|
||||
{plugin.source_link && (
|
||||
<a
|
||||
href={plugin.source_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="github-link-secondary"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
View Source Code
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="sidebar-stats">
|
||||
<div className="stat-item"><strong>{plugin.downloads.toLocaleString()}</strong> Downloads</div>
|
||||
<div className="stat-item"><strong>{plugin.views.toLocaleString()}</strong> Views</div>
|
||||
<div className="stat-item">Category: <strong>{plugin.category}</strong></div>
|
||||
|
||||
{/* ADD THESE TWO */}
|
||||
{plugin.version && (
|
||||
<div className="stat-item">Version: <strong>{plugin.version}</strong></div>
|
||||
)}
|
||||
|
||||
<div className="divider-sm" />
|
||||
{user && !reportDone && (
|
||||
<>
|
||||
{!reportOpen ? (
|
||||
<button className="btn-report" onClick={() => setReportOpen(true)}>
|
||||
🚩 Report plugin
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={submitReport} className="report-form">
|
||||
<label className="report-form-label">Reason for report</label>
|
||||
<div className="report-reasons">
|
||||
{REPORT_REASONS.map(reason => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
className={`report-reason-chip ${reportSelected === reason ? 'selected' : ''}`}
|
||||
onClick={() => { setReportSelected(reason); setReportCustom(''); }}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{reportSelected === 'Other' && (
|
||||
<textarea
|
||||
className="report-textarea"
|
||||
placeholder="Describe the issue…"
|
||||
value={reportCustom}
|
||||
onChange={e => setReportCustom(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<div className="report-form-actions">
|
||||
<button type="button" className="btn-report-cancel" onClick={resetReport}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-report-submit"
|
||||
disabled={
|
||||
reportSubmitting ||
|
||||
!reportSelected ||
|
||||
(reportSelected === 'Other' && !reportCustom.trim())
|
||||
}
|
||||
>
|
||||
{reportSubmitting ? 'Sending…' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{reportDone && (
|
||||
<p className="report-done">✓ Report submitted. Thanks for keeping the marketplace safe.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<form onSubmit={submitReview} className="review-submit-form">
|
||||
<h3 className="review-form-title">Leave a Review</h3>
|
||||
<div className="form-group">
|
||||
<label>Your Rating</label>
|
||||
<StarRating rating={reviewForm.rating} onRate={val => setReviewForm({ ...reviewForm, rating: val })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Your Experience</label>
|
||||
<textarea
|
||||
placeholder="What did you like? What could be better?"
|
||||
value={reviewForm.comment}
|
||||
maxLength={MAX_REVIEW_LENGTH}
|
||||
onChange={e => setReviewForm({ ...reviewForm, comment: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit">Post Review</button>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<div className="auth-notice">
|
||||
Please <Link to="/login">login</Link> to rate this plugin.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* LIGHTBOX MODAL */}
|
||||
{lightboxImage && (
|
||||
<div className="lightbox-overlay" onClick={() => setLightboxImage(null)}>
|
||||
<div className="lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||||
<img src={lightboxImage} alt="Expanded view" className="lightbox-img" />
|
||||
<button className="lightbox-close" onClick={() => setLightboxImage(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/* SIDEBAR */}
|
||||
<aside className="plugin-sidebar">
|
||||
<div className="sidebar-widget">
|
||||
|
||||
{/* Price / owned indicator */}
|
||||
<div className="price-hero-section">
|
||||
{plugin.type === 'paid' && ownsPlugin ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
Purchased
|
||||
</div>
|
||||
) : plugin.type === 'paid' ? (
|
||||
<div>
|
||||
{plugin.sale_active && plugin.sale_discount_percent > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||||
{/* Sale price prominent */}
|
||||
<PriceDisplay cents={Math.round(plugin.price_cents * (1 - plugin.sale_discount_percent / 100))} />
|
||||
{/* Original price struck through */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
|
||||
€{(plugin.price_cents / 100).toFixed(2)}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.15)', border: '1px solid rgba(255,107,107,0.35)', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px', letterSpacing: '0.04em' }}>
|
||||
-{plugin.sale_discount_percent}% SALE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PriceDisplay cents={plugin.price_cents} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="price-free">{plugin.type === 'adwall' ? 'AD SUPPORTED' : 'FREE'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-download-main"
|
||||
onClick={handleDownload}
|
||||
disabled={plugin.type === 'paid' && !ownershipChecked}
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
|
||||
{plugin.source_link && (
|
||||
<a
|
||||
href={plugin.source_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="github-link-secondary"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
View Source Code
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="sidebar-stats">
|
||||
<div className="stat-item"><strong>{plugin.downloads.toLocaleString()}</strong> Downloads</div>
|
||||
<div className="stat-item"><strong>{plugin.views.toLocaleString()}</strong> Views</div>
|
||||
<div className="stat-item">Category: <strong>{plugin.category}</strong></div>
|
||||
|
||||
{plugin.version && (
|
||||
<div className="stat-item">Version: <strong>{plugin.version}</strong></div>
|
||||
)}
|
||||
|
||||
<div className="divider-sm" />
|
||||
{user && !reportDone && (
|
||||
<>
|
||||
{!reportOpen ? (
|
||||
<button className="btn-report" onClick={() => setReportOpen(true)}>
|
||||
🚩 Report plugin
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={submitReport} className="report-form">
|
||||
<label className="report-form-label">Reason for report</label>
|
||||
<div className="report-reasons">
|
||||
{REPORT_REASONS.map(reason => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
className={`report-reason-chip ${reportSelected === reason ? 'selected' : ''}`}
|
||||
onClick={() => { setReportSelected(reason); setReportCustom(''); }}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{reportSelected === 'Other' && (
|
||||
<textarea
|
||||
className="report-textarea"
|
||||
placeholder="Describe the issue…"
|
||||
value={reportCustom}
|
||||
onChange={e => setReportCustom(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<div className="report-form-actions">
|
||||
<button type="button" className="btn-report-cancel" onClick={resetReport}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-report-submit"
|
||||
disabled={
|
||||
reportSubmitting ||
|
||||
!reportSelected ||
|
||||
(reportSelected === 'Other' && !reportCustom.trim())
|
||||
}
|
||||
>
|
||||
{reportSubmitting ? 'Sending…' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{reportDone && (
|
||||
<p className="report-done">✓ Report submitted. Thanks for keeping the marketplace safe.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* LIGHTBOX MODAL */}
|
||||
{lightboxImage && (
|
||||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
||||
<div className="lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||||
<img src={lightboxImage} alt="Expanded view" className="lightbox-img" />
|
||||
<button className="lightbox-close" onClick={closeLightbox}>
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* Left arrow — only shown when multiple screenshots exist */}
|
||||
{screenshots.length > 1 && (
|
||||
<button
|
||||
className="lightbox-arrow lightbox-arrow-left"
|
||||
onClick={(e) => { e.stopPropagation(); lightboxPrev(); }}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right arrow */}
|
||||
{screenshots.length > 1 && (
|
||||
<button
|
||||
className="lightbox-arrow lightbox-arrow-right"
|
||||
onClick={(e) => { e.stopPropagation(); lightboxNext(); }}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dot indicators */}
|
||||
{screenshots.length > 1 && (
|
||||
<div className="lightbox-dots">
|
||||
{screenshots.map((_: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`lightbox-dot ${i === lightboxIndex ? 'active' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); openLightbox(i); }}
|
||||
aria-label={`Go to screenshot ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user