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 && (
+
+

{
+ const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0].path));
+ openLightbox(idx >= 0 ? idx : 0);
+ }}
/>
-
+
+ {plugin.screenshots.map((shot: any) => (
+

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 && (
-
-

setLightboxImage(mainScreenshot || plugin.screenshots[0].path)}
- />
-
- {plugin.screenshots.map((shot: any) => (
-

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!
-
-
- ) : (
-
- )
- ) : (
-
- Please login to rate this plugin.
-
- )}
-
-
- {/* SIDEBAR */}
-
+ ) : (
+
+ )
+ ) : (
+
+ Please login to rate this plugin.
+ )}
+
+
- {/* LIGHTBOX MODAL */}
- {lightboxImage && (
-
setLightboxImage(null)}>
-
e.stopPropagation()}>
-

-
-
+ {/* SIDEBAR */}
+
+
+
+ {/* LIGHTBOX MODAL */}
+ {lightboxImage && (
+
+
e.stopPropagation()}>
+

+
+
+ {/* Left arrow — only shown when multiple screenshots exist */}
+ {screenshots.length > 1 && (
+
+ )}
+
+ {/* Right arrow */}
+ {screenshots.length > 1 && (
+
+ )}
+
+ {/* Dot indicators */}
+ {screenshots.length > 1 && (
+
+ {screenshots.map((_: any, i: number) => (
+
)}
+
+
+ )}
);
};
-export default PluginDetail;
\ No newline at end of file
+export default PluginDetail;