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",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.3",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.7",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.15.0",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.8.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.5",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-is": "^19.2.4",
|
"react-is": "^19.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@ -37,8 +37,8 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.1",
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@ -1042,13 +1042,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.5.2",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
@ -1978,9 +1978,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.334",
|
"version": "1.5.335",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz",
|
||||||
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
|
"integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@ -4829,9 +4829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
22
package.json
22
package.json
@ -12,18 +12,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.3",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.7",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.15.0",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.8.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.5",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-is": "^19.2.4",
|
"react-is": "^19.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@ -40,10 +40,10 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.1",
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.8"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,27 +9,27 @@ import SEO from '../components/SEO';
|
|||||||
|
|
||||||
const REPORT_REASONS = [
|
const REPORT_REASONS = [
|
||||||
'Malware or malicious code',
|
'Malware or malicious code',
|
||||||
'Stolen or plagiarised content',
|
'Stolen or plagiarised content',
|
||||||
'Misleading description',
|
'Misleading description',
|
||||||
'Broken / non-functional',
|
'Broken / non-functional',
|
||||||
'Inappropriate content',
|
'Inappropriate content',
|
||||||
'Spam or fake listing',
|
'Spam or fake listing',
|
||||||
'Other',
|
'Other',
|
||||||
];
|
];
|
||||||
|
|
||||||
const MAX_REVIEW_LENGTH = 2000;
|
const MAX_REVIEW_LENGTH = 2000;
|
||||||
|
|
||||||
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
|
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
|
||||||
<div className="star-rating-container">
|
<div className="star-rating-container">
|
||||||
{[...Array(max)].map((_, i) => (
|
{[...Array(max)].map((_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
|
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
|
||||||
onClick={() => onRate && onRate(i + 1)}
|
onClick={() => onRate && onRate(i + 1)}
|
||||||
>
|
>
|
||||||
{i < rating ? '★' : '☆'}
|
{i < rating ? '★' : '☆'}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -39,10 +39,10 @@ const PriceDisplay = ({ cents }: { cents: number }) => {
|
|||||||
const remainder = (cents % 100).toString().padStart(2, '0');
|
const remainder = (cents % 100).toString().padStart(2, '0');
|
||||||
return (
|
return (
|
||||||
<div className="price-container">
|
<div className="price-container">
|
||||||
<span className="currency-symbol">€</span>
|
<span className="currency-symbol">€</span>
|
||||||
<span className="price-dollars">{euros}</span>
|
<span className="price-dollars">{euros}</span>
|
||||||
<span className="price-decimal">.</span>
|
<span className="price-decimal">.</span>
|
||||||
<span className="price-cents">{remainder}</span>
|
<span className="price-cents">{remainder}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -73,6 +73,7 @@ const PluginDetail = () => {
|
|||||||
const [adShown, setAdShown] = useState(false);
|
const [adShown, setAdShown] = useState(false);
|
||||||
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
|
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
|
||||||
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number>(0);
|
||||||
const [selectedLanguage] = useState('en');
|
const [selectedLanguage] = useState('en');
|
||||||
const [currentDescription, setCurrentDescription] = useState('');
|
const [currentDescription, setCurrentDescription] = useState('');
|
||||||
const [setLoading] = useState(true);
|
const [setLoading] = useState(true);
|
||||||
@ -117,9 +118,9 @@ const PluginDetail = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !id) { setOwnershipChecked(true); return; }
|
if (!user || !id) { setOwnershipChecked(true); return; }
|
||||||
api.get(`/plugins/${id}/owned`)
|
api.get(`/plugins/${id}/owned`)
|
||||||
.then(res => setOwnsPlugin(res.data.owned === true))
|
.then(res => setOwnsPlugin(res.data.owned === true))
|
||||||
.catch(() => setOwnsPlugin(false))
|
.catch(() => setOwnsPlugin(false))
|
||||||
.finally(() => setOwnershipChecked(true));
|
.finally(() => setOwnershipChecked(true));
|
||||||
}, [user, id]);
|
}, [user, id]);
|
||||||
|
|
||||||
// ── Translations ─────────────────────────────────────────────────────────
|
// ── Translations ─────────────────────────────────────────────────────────
|
||||||
@ -141,6 +142,42 @@ const PluginDetail = () => {
|
|||||||
}
|
}
|
||||||
}, [plugin, selectedLanguage]);
|
}, [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(() => {
|
const hasReviewed = useMemo(() => {
|
||||||
if (!user || !plugin?.reviews || !Array.isArray(plugin.reviews)) return false;
|
if (!user || !plugin?.reviews || !Array.isArray(plugin.reviews)) return false;
|
||||||
return plugin.reviews.some((r: any) => r.username === user.username);
|
return plugin.reviews.some((r: any) => r.username === user.username);
|
||||||
@ -265,14 +302,14 @@ const PluginDetail = () => {
|
|||||||
const downloadLabel = (() => {
|
const downloadLabel = (() => {
|
||||||
const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : '';
|
const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : '';
|
||||||
|
|
||||||
if (plugin.type === 'paid') {
|
if (plugin.type === 'paid') {
|
||||||
if (!ownershipChecked) return 'Loading…';
|
if (!ownershipChecked) return 'Loading…';
|
||||||
return ownsPlugin ? `Download ${sizeStr}` : 'Purchase Plugin';
|
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 = {
|
const productSchema = {
|
||||||
@ -299,334 +336,372 @@ const PluginDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<SEO
|
<SEO
|
||||||
title={plugin.name}
|
title={plugin.name}
|
||||||
description={currentDescription.substring(0, 160)}
|
description={currentDescription.substring(0, 160)}
|
||||||
ogType="product"
|
ogType="product"
|
||||||
ogImage={plugin.preview_path || "/icon.png"}
|
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">
|
<div className="thumbnail-strip">
|
||||||
{JSON.stringify(productSchema)}
|
{plugin.screenshots.map((shot: any) => (
|
||||||
</script>
|
<img
|
||||||
|
key={shot.id}
|
||||||
|
src={shot.path}
|
||||||
|
alt="Thumbnail"
|
||||||
|
className={`thumbnail ${mainScreenshot === shot.path ? 'active' : ''}`}
|
||||||
|
onClick={() => setMainScreenshot(shot.path)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Payment success banner ── */}
|
{/* DESCRIPTION */}
|
||||||
{showPaymentSuccess && (
|
<div className="markdown-content">
|
||||||
<div style={{
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown>
|
||||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
</div>
|
||||||
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 */}
|
<hr className="divider" />
|
||||||
<div className="plugin-detail-header">
|
|
||||||
|
{/* 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
|
<div
|
||||||
className="plugin-icon large"
|
className="review-bar-fill"
|
||||||
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
|
style={{ width: `${(stats.distribution[num as keyof typeof stats.distribution] / stats.total) * 100}%` }}
|
||||||
>
|
/>
|
||||||
{!plugin.preview_path && plugin.name.charAt(0)}
|
|
||||||
</div>
|
</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>
|
||||||
<h1 className="plugin-title">{plugin.name}</h1>
|
<div className="review-already-title">Review posted</div>
|
||||||
<p className="dev-name">by {plugin.dev_name}</p>
|
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
|
||||||
</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>
|
</div>
|
||||||
|
) : (
|
||||||
{/* SIDEBAR */}
|
<form onSubmit={submitReview} className="review-submit-form">
|
||||||
<aside className="plugin-sidebar">
|
<h3 className="review-form-title">Leave a Review</h3>
|
||||||
<div className="sidebar-widget">
|
<div className="form-group">
|
||||||
|
<label>Your Rating</label>
|
||||||
{/* Price / owned indicator */}
|
<StarRating rating={reviewForm.rating} onRate={val => setReviewForm({ ...reviewForm, rating: val })} />
|
||||||
<div className="price-hero-section">
|
</div>
|
||||||
{plugin.type === 'paid' && ownsPlugin ? (
|
<div className="form-group">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
|
<label>Your Experience</label>
|
||||||
<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">
|
<textarea
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
placeholder="What did you like? What could be better?"
|
||||||
</svg>
|
value={reviewForm.comment}
|
||||||
Purchased
|
maxLength={MAX_REVIEW_LENGTH}
|
||||||
</div>
|
onChange={e => setReviewForm({ ...reviewForm, comment: e.target.value })}
|
||||||
) : plugin.type === 'paid' ? (
|
required
|
||||||
<div>
|
/>
|
||||||
{plugin.sale_active && plugin.sale_discount_percent > 0 ? (
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
<button className="btn btn-primary" type="submit">Post Review</button>
|
||||||
{/* Sale price prominent */}
|
</form>
|
||||||
<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' }}>
|
<div className="auth-notice">
|
||||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
|
Please <Link to="/login">login</Link> to rate this plugin.
|
||||||
€{(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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* LIGHTBOX MODAL */}
|
{/* SIDEBAR */}
|
||||||
{lightboxImage && (
|
<aside className="plugin-sidebar">
|
||||||
<div className="lightbox-overlay" onClick={() => setLightboxImage(null)}>
|
<div className="sidebar-widget">
|
||||||
<div className="lightbox-content" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<img src={lightboxImage} alt="Expanded view" className="lightbox-img" />
|
{/* Price / owned indicator */}
|
||||||
<button className="lightbox-close" onClick={() => setLightboxImage(null)}>
|
<div className="price-hero-section">
|
||||||
×
|
{plugin.type === 'paid' && ownsPlugin ? (
|
||||||
</button>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||||||
</div>
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PluginDetail;
|
export default PluginDetail;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user