From 5b99a76745e2771385efe1115a36900fc77090ed Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sat, 11 Apr 2026 11:10:50 +0200 Subject: [PATCH] chore: make able to switch screenshots in zoom --- package-lock.json | 40 +- package.json | 22 +- src/pages/PluginDetail.tsx | 761 ++++++++++++++++++++----------------- 3 files changed, 449 insertions(+), 374 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea4f0e3..d46dde5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index b45580f..ff799bd 100644 --- a/package.json +++ b/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" } } diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index bf86cd6..5a9ed43 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -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 }) => (
- {[...Array(max)].map((_, i) => ( - onRate && onRate(i + 1)} - > - {i < rating ? '★' : '☆'} - - ))} + {[...Array(max)].map((_, i) => ( + onRate && onRate(i + 1)} + > + {i < rating ? '★' : '☆'} + + ))}
); @@ -39,10 +39,10 @@ const PriceDisplay = ({ cents }: { cents: number }) => { const remainder = (cents % 100).toString().padStart(2, '0'); return (
- € - {euros} - . - {remainder} + € + {euros} + . + {remainder}
); }; @@ -73,6 +73,7 @@ const PluginDetail = () => { const [adShown, setAdShown] = useState(false); const [mainScreenshot, setMainScreenshot] = useState(null); const [lightboxImage, setLightboxImage] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(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,334 +336,372 @@ const PluginDetail = () => { return (
- + + + {/* ── Payment success banner ── */} + {showPaymentSuccess && ( +
+ 🎉 +
+
+ Purchase successful! +
+
+ You now own {plugin.name}. Click the download button to get your WASM file. +
+
+ +
+ )} + + {/* HEADER */} +
+
+ {!plugin.preview_path && plugin.name.charAt(0)} +
+
+

{plugin.name}

+

by {plugin.dev_name}

+
+
+ +
+
+ + {/* SCREENSHOTS */} + {Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && ( +
+ Main { + const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0].path)); + openLightbox(idx >= 0 ? idx : 0); + }} /> - +
+ {plugin.screenshots.map((shot: any) => ( + Thumbnail setMainScreenshot(shot.path)} + /> + ))} +
+
+ )} - {/* ── Payment success banner ── */} - {showPaymentSuccess && ( -
- 🎉 -
-
- Purchase successful! -
-
- You now own {plugin.name}. Click the download button to get your WASM file. -
-
- -
- )} + {/* DESCRIPTION */} +
+ {currentDescription} +
- {/* HEADER */} -
+
+ + {/* REVIEWS */} +
+ Ratings & Reviews + + {stats ? ( +
+
+
{stats.avg}
+
+ {[1, 2, 3, 4, 5].map(n => ( + ★ + ))} +
+
{stats.total.toLocaleString()} Ratings
+
+
+ {[5, 4, 3, 2, 1].map(num => ( +
+ {num} +
- {!plugin.preview_path && plugin.name.charAt(0)} + className="review-bar-fill" + style={{ width: `${(stats.distribution[num as keyof typeof stats.distribution] / stats.total) * 100}%` }} + />
+ + {stats.distribution[num as keyof typeof stats.distribution]} + +
+ ))} +
+
+ ) : ( +
No reviews yet. Be the first to leave one!
+ )} + +
+ {Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => ( +
+
+
+ {getInitials(r.username)} +
+
+ {r.username} + Verified User +
+
+
+ {[1, 2, 3, 4, 5].map(n => ( + ★ + ))} +
+

{r.comment}

+
+ ))} +
+ + {user ? ( + hasReviewed ? ( +
+ ✓
-

{plugin.name}

-

by {plugin.dev_name}

+
Review posted
+
You've already reviewed this plugin. Thanks for the feedback!
-
- -
-
- - {/* SCREENSHOTS */} - {Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && ( -
- Main setLightboxImage(mainScreenshot || plugin.screenshots[0].path)} - /> -
- {plugin.screenshots.map((shot: any) => ( - Thumbnail setMainScreenshot(shot.path)} - /> - ))} -
-
- )} - - {/* DESCRIPTION */} -
- {currentDescription} -
- -
- - {/* REVIEWS */} -
- Ratings & Reviews - - {stats ? ( -
-
-
{stats.avg}
-
- {[1, 2, 3, 4, 5].map(n => ( - ★ - ))} -
-
{stats.total.toLocaleString()} Ratings
-
-
- {[5, 4, 3, 2, 1].map(num => ( -
- {num} -
-
-
- - {stats.distribution[num as keyof typeof stats.distribution]} - -
- ))} -
-
- ) : ( -
No reviews yet. Be the first to leave one!
- )} - -
- {Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => ( -
-
-
- {getInitials(r.username)} -
-
- {r.username} - Verified User -
-
-
- {[1, 2, 3, 4, 5].map(n => ( - ★ - ))} -
-

{r.comment}

-
- ))} -
- - {user ? ( - hasReviewed ? ( -
- ✓ -
-
Review posted
-
You've already reviewed this plugin. Thanks for the feedback!
-
-
- ) : ( -
-

Leave a Review

-
- - setReviewForm({ ...reviewForm, rating: val })} /> -
-
- -