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

View File

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

View File

@ -9,12 +9,12 @@ 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;
@ -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);
@ -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);
@ -357,7 +394,10 @@ const PluginDetail = () => {
alt="Main" alt="Main"
className="main-screenshot" className="main-screenshot"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => setLightboxImage(mainScreenshot || plugin.screenshots[0].path)} onClick={() => {
const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0].path));
openLightbox(idx >= 0 ? idx : 0);
}}
/> />
<div className="thumbnail-strip"> <div className="thumbnail-strip">
{plugin.screenshots.map((shot: any) => ( {plugin.screenshots.map((shot: any) => (
@ -549,7 +589,6 @@ const PluginDetail = () => {
<div className="stat-item"><strong>{plugin.views.toLocaleString()}</strong> Views</div> <div className="stat-item"><strong>{plugin.views.toLocaleString()}</strong> Views</div>
<div className="stat-item">Category: <strong>{plugin.category}</strong></div> <div className="stat-item">Category: <strong>{plugin.category}</strong></div>
{/* ADD THESE TWO */}
{plugin.version && ( {plugin.version && (
<div className="stat-item">Version: <strong>{plugin.version}</strong></div> <div className="stat-item">Version: <strong>{plugin.version}</strong></div>
)} )}
@ -616,12 +655,48 @@ const PluginDetail = () => {
{/* LIGHTBOX MODAL */} {/* LIGHTBOX MODAL */}
{lightboxImage && ( {lightboxImage && (
<div className="lightbox-overlay" onClick={() => setLightboxImage(null)}> <div className="lightbox-overlay" onClick={closeLightbox}>
<div className="lightbox-content" onClick={(e) => e.stopPropagation()}> <div className="lightbox-content" onClick={(e) => e.stopPropagation()}>
<img src={lightboxImage} alt="Expanded view" className="lightbox-img" /> <img src={lightboxImage} alt="Expanded view" className="lightbox-img" />
<button className="lightbox-close" onClick={() => setLightboxImage(null)}> <button className="lightbox-close" onClick={closeLightbox}>
&times; &times;
</button> </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> </div>
)} )}