chore: make able to switch screenshots in zoom

This commit is contained in:
Alexander Medvedev 2026-04-11 11:10:50 +02:00
parent 0079da3b49
commit 5b99a76745
3 changed files with 449 additions and 374 deletions

40
package-lock.json generated
View File

@ -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"
},

View File

@ -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"
}
}

View File

@ -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 &amp; 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 &amp; 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)}>
&times;
</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}>
&times;
</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"
>
&#8249;
</button>
)}
{/* Right arrow */}
{screenshots.length > 1 && (
<button
className="lightbox-arrow lightbox-arrow-right"
onClick={(e) => { e.stopPropagation(); lightboxNext(); }}
aria-label="Next screenshot"
>
&#8250;
</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>
);
};