import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useLocation, Link } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../App';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useNavigate } from 'react-router-dom';
import SEO from '../components/SEO';
import { PluginVideoPlayer } from '../components/PluginVideoPlayer';
import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock, Terminal, Copy, Key, ChevronDown, ChevronUp } from 'lucide-react';
import type { PluginDetail, PluginCommand } from '../types/plugin';
const getYoutubeVideoId = (url: string) => {
if (!url) return null;
try {
if (url.includes('youtu.be/')) {
return url.split('youtu.be/')[1].split(/[?#]/)[0];
} else if (url.includes('youtube.com/embed/')) {
return url.split('youtube.com/embed/')[1].split(/[?#]/)[0];
} else if (url.includes('youtube.com/shorts/')) {
return url.split('youtube.com/shorts/')[1].split(/[?#]/)[0];
} else if (url.includes('v=')) {
return url.split('v=')[1].split(/[&?#]/)[0];
}
return null;
} catch {
return null;
}
};
const REPORT_REASONS = [
'Malware or malicious code',
'Stolen or plagiarised content',
'Misleading description',
'Broken / non-functional',
'Inappropriate content',
'Spam or fake listing',
'Other',
];
const REVIEW_REPORT_REASONS = [
'Inappropriate language',
'Spam or advertising',
'Hate speech or harassment',
'Off-topic or irrelevant',
'Other',
];
const MAX_REVIEW_LENGTH = 2000;
const safeMarkdownComponents = {
a: ({ href, children, ...props }: any) => {
const isSafe = typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/'));
if (!isSafe) {
return {children} ;
}
const isExternal = href.startsWith('http://') || href.startsWith('https://');
return (
{children}
);
},
};
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
{[...Array(max)].map((_, i) => (
onRate && onRate(i + 1)}
style={{ display: 'inline-flex', alignItems: 'center' }}
>
))}
);
const ExpandableText = ({ text, limit = 500, className, pStyle }: { text: string; limit?: number; className?: string; pStyle?: React.CSSProperties }) => {
const [isExpanded, setIsExpanded] = useState(false);
const shouldTruncate = text.length > limit;
const displayText = isExpanded ? text : text.slice(0, limit) + (shouldTruncate ? '...' : '');
return (
{displayText}
{shouldTruncate && (
setIsExpanded(!isExpanded)}
style={{
background: 'none',
border: 'none',
color: 'var(--primary)',
padding: 0,
marginTop: '8px',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.02em'
}}
>
{isExpanded ? 'Show Less' : 'Expand'}
)}
);
};
const PriceDisplay = ({ cents }: { cents: number }) => {
if (!cents && cents !== 0) return null;
const euros = Math.floor(cents / 100);
const remainder = (cents % 100).toString().padStart(2, '0');
return (
€
{euros}
.
{remainder}
);
};
const getAvatarClass = (username: string) => {
const classes = ['av-a', 'av-b', 'av-c', 'av-d', 'av-e'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
return classes[Math.abs(hash) % classes.length];
};
const getInitials = (username: string) => {
const parts = username.trim().split(/[\s_\-\.]+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return username.slice(0, 2).toUpperCase();
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
const day = d.getDate();
const month = d.toLocaleString('en-US', { month: 'short' });
const year = d.getFullYear();
return `${day} ${month}, ${year}`;
};
const getLocalizedCommandDescription = (
rawDescription: string | Record | undefined | null,
activeLanguage: string
): string => {
if (!rawDescription) return '';
if (typeof rawDescription === 'object') {
const full = activeLanguage;
const short = activeLanguage.slice(0, 2);
return (
rawDescription[full] ||
rawDescription[short] ||
rawDescription['en-US'] ||
rawDescription['en'] ||
rawDescription['en-GB'] ||
Object.values(rawDescription)[0] ||
''
);
}
if (typeof rawDescription === 'string') {
try {
const parsed = JSON.parse(rawDescription);
if (parsed && typeof parsed === 'object') {
return getLocalizedCommandDescription(parsed, activeLanguage);
}
return rawDescription;
} catch {
return rawDescription;
}
}
return '';
};
const PluginCommandsSidebarWidget = ({ commands }: { commands?: PluginCommand[] }) => {
const { t, i18n } = useTranslation();
const [copiedId, setCopiedId] = useState(null);
const [expanded, setExpanded] = useState(false);
if (!commands || commands.length === 0) return null;
const handleCopy = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const activeLang = i18n.language || 'en';
const visibleCommands = expanded ? commands : commands.slice(0, 4);
return (
{t('plugin.commands.title', 'Commands & Permissions')}
{commands.length}
{visibleCommands.map((cmd: any, index: number) => {
const desc = getLocalizedCommandDescription(cmd.description, activeLang);
const cmdCopyKey = `cmd-${cmd.id || index}`;
const permCopyKey = `perm-${cmd.id || index}`;
const formattedName = cmd.name?.startsWith('/') ? cmd.name : `/${cmd.name}`;
return (
{/* Command row */}
{formattedName}
handleCopy(formattedName, cmdCopyKey)}
title={t('plugin.commands.copy_command', 'Copy command')}
style={{
background: copiedId === cmdCopyKey ? 'rgba(74, 222, 128, 0.15)' : 'rgba(255, 255, 255, 0.06)',
border: `1px solid ${copiedId === cmdCopyKey ? 'rgba(74, 222, 128, 0.4)' : 'rgba(255, 255, 255, 0.1)'}`,
borderRadius: '4px',
color: copiedId === cmdCopyKey ? 'var(--success, #4ade80)' : 'var(--text-muted)',
cursor: 'pointer',
padding: '3px 6px',
display: 'flex',
alignItems: 'center',
gap: '3px',
fontSize: '0.68rem',
fontWeight: 600,
transition: 'all 0.15s ease',
}}
>
{copiedId === cmdCopyKey ? (
<>
{t('plugin.commands.copied', 'Copied')}
>
) : (
)}
{/* Permission row */}
{cmd.permission ? (
{cmd.permission}
handleCopy(cmd.permission, permCopyKey)}
title={t('plugin.commands.copy_permission', 'Copy permission')}
style={{
background: 'none',
border: 'none',
color: copiedId === permCopyKey ? 'var(--success, #4ade80)' : 'var(--text-muted)',
cursor: 'pointer',
padding: '2px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
}}
>
{copiedId === permCopyKey ? : }
) : null}
{/* Description */}
{desc ? (
{desc}
) : null}
);
})}
{commands.length > 4 && (
setExpanded(!expanded)}
style={{
width: '100%',
marginTop: '0.75rem',
background: 'rgba(255, 255, 255, 0.04)',
border: '1px solid var(--border)',
borderRadius: '6px',
color: 'var(--text)',
padding: '6px 10px',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
transition: 'background 0.15s ease',
}}
>
{expanded ? (
<>
{t('plugin.commands.show_less', 'Show less')}
>
) : (
<>
{t('plugin.commands.show_all', 'Show all {{count}} commands', { count: commands.length })}
>
)}
)}
);
};
const PluginDetail = () => {
const { t, i18n } = useTranslation();
const { id: rawId, slug } = useParams();
const id = rawId ? rawId.split('-')[0] : rawId;
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [plugin, setPlugin] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
const [adShown, setAdShown] = useState(false);
const [mainScreenshot, setMainScreenshot] = useState(null);
const [lightboxImage, setLightboxImage] = useState(null);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [currentDescription, setCurrentDescription] = useState('');
// Coupon state
const [couponCode, setCouponCode] = useState('');
const [appliedCoupon, setAppliedCoupon] = useState(null);
const [couponDiscount, setCouponDiscount] = useState(0);
const [couponMessage, setCouponMessage] = useState<{ text: string; isError: boolean } | null>(null);
const [validatingCoupon, setValidatingCoupon] = useState(false);
const handleApplyCoupon = async () => {
if (!couponCode.trim()) return;
setValidatingCoupon(true);
setCouponMessage(null);
try {
const res = await api.get(`/plugins/${id}/validate-coupon?code=${couponCode.trim()}`);
if (res.data.valid) {
setAppliedCoupon(couponCode.trim().toUpperCase());
setCouponDiscount(res.data.discount_percent);
setCouponMessage({ text: res.data.message, isError: false });
} else {
setAppliedCoupon(null);
setCouponDiscount(0);
setCouponMessage({ text: res.data.message || 'Invalid coupon code', isError: true });
}
} catch {
setAppliedCoupon(null);
setCouponDiscount(0);
setCouponMessage({ text: 'Failed to validate coupon.', isError: true });
} finally {
setValidatingCoupon(false);
}
};
const handleClearCoupon = () => {
setCouponCode('');
setAppliedCoupon(null);
setCouponDiscount(0);
setCouponMessage(null);
};
// Ownership
const [ownsPlugin, setOwnsPlugin] = useState(false);
const [ownershipChecked, setOwnershipChecked] = useState(false);
// Payment success banner
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false);
// Report
const [reportOpen, setReportOpen] = useState(false);
const [reportSelected, setReportSelected] = useState('');
const [reportCustom, setReportCustom] = useState('');
const [reportSubmitting, setReportSubmitting] = useState(false);
const [reportDone, setReportDone] = useState(false);
const [changelogOpen, setChangelogOpen] = useState(false);
// Review Report State
const [reviewReportOpen, setReviewReportOpen] = useState(null);
const [reviewReportSelected, setReviewReportSelected] = useState('');
const [reviewReportCustom, setReviewReportCustom] = useState('');
const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false);
const [replyOpen, setReplyOpen] = useState(null);
const [replyText, setReplyText] = useState('');
const [replySubmitting, setReplySubmitting] = useState(false);
const isOwner = user && plugin && user.id === plugin.dev_id;
const [reportedReviews, setReportedReviews] = useState>(new Set());
// ── Detect ?payment=success and strip from URL ───────────────────────────
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('payment') === 'success') {
setShowPaymentSuccess(true);
window.history.replaceState({}, '', location.pathname);
}
}, []);
// ── Fetch plugin ─────────────────────────────────────────────────────────
const fetchPlugin = () => {
setLoading(true);
setError(null);
api.get(`/plugins/${id}`).then(res => {
setPlugin(res.data);
if (res.data.youtube_video_url) {
setMainScreenshot('video');
} else if (res.data.screenshots?.length > 0) {
setMainScreenshot(res.data.screenshots[0].path);
}
// Canonical URL check & replace
if (res.data.name) {
const expectedSlug = res.data.name.toLowerCase().trim().replace(/[\s_\.\-]+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
const targetPath = expectedSlug ? `/plugin/${res.data.id}-${expectedSlug}` : `/plugin/${res.data.id}`;
if (location.pathname !== targetPath || slug !== expectedSlug) {
window.history.replaceState({}, '', targetPath + location.search);
}
}
})
.catch(err => {
console.error('Failed to load plugin:', err);
setError('Plugin not found or failed to load.');
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => { fetchPlugin(); }, [id]);
// ── Check ownership ──────────────────────────────────────────────────────
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));
}, [user, id]);
// ── Translations ─────────────────────────────────────────────────────────
useEffect(() => {
if (plugin?.translated_descriptions) {
try {
const translations = typeof plugin.translated_descriptions === 'string'
? JSON.parse(plugin.translated_descriptions)
: plugin.translated_descriptions;
const activeLang = (i18n.language || 'en').slice(0, 2);
setCurrentDescription(
translations[activeLang] ||
translations['en'] ||
translations['en-US'] ||
(Object.values(translations)[0] as string) ||
''
);
} catch {
setCurrentDescription('Description not available.');
}
} else if (plugin?.description) {
setCurrentDescription(plugin.description);
}
}, [plugin, i18n.language]);
// ── 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);
}, [plugin?.reviews, user]);
const stats = useMemo(() => {
if (!plugin?.reviews || !Array.isArray(plugin.reviews) || plugin.reviews.length === 0) return null;
const total = plugin.reviews.length;
const distribution = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
let sum = 0;
plugin.reviews.forEach((r: any) => {
distribution[r.rating as keyof typeof distribution]++;
sum += r.rating;
});
return { avg: (sum / total).toFixed(1), total, distribution };
}, [plugin?.reviews]);
// ── Trigger a blob download ──────────────────────────────────────────────
const triggerDownload = async (endpoint: string) => {
if (!plugin) return;
const res = await api.get(endpoint, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${plugin.name.replace(/\s+/g, '_')}.wasm`;
a.click();
URL.revokeObjectURL(url);
};
// ── Download / purchase handler ──────────────────────────────────────────
const handleDownload = async () => {
if (!plugin) return;
if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } });
if (plugin.type === 'paid') {
// Already owns it — download directly, no checkout
if (ownsPlugin) {
if (plugin.is_preorder) {
alert('You have successfully pre-ordered this plugin! You will receive an automated email notification the moment the developer releases the downloadable version.');
return;
}
try {
await triggerDownload(`/plugins/${id}/download/premium`);
} catch {
alert('Download failed. Please try again.');
}
return;
}
// Does not own — go to Stripe checkout
try {
const url = appliedCoupon ? `/plugins/${id}/checkout?coupon=${appliedCoupon}` : `/plugins/${id}/checkout`;
const res = await api.post(url);
if (res.data.url) window.location.href = res.data.url;
} catch (err: any) {
if (err?.response?.status === 409) {
// Webhook already recorded purchase before redirect resolved
setOwnsPlugin(true);
if (plugin.is_preorder) {
alert('You have pre-ordered this plugin! You will receive an email as soon as it releases.');
} else {
alert('You already own this plugin — downloading now.');
await triggerDownload(`/plugins/${id}/download/premium`);
}
} else {
alert('Checkout failed. Please try again.');
}
} finally {
}
return;
}
// Adwall
if (plugin.type === 'adwall' && !adShown) {
alert('Wait 5 seconds to support the developer...');
setTimeout(() => setAdShown(true), 5000);
return;
}
// Free / adwall (after ad)
try {
await triggerDownload(`/plugins/${id}/download`);
} catch {
alert('Download failed. Please try again.');
}
};
const submitReview = async (e: React.FormEvent) => {
e.preventDefault();
const comment = reviewForm.comment.trim();
if (!comment || comment.length > MAX_REVIEW_LENGTH) {
return;
}
await api.post(`/plugins/${id}/reviews`, reviewForm);
fetchPlugin();
setReviewForm({ rating: 5, comment: '' });
};
const deleteReview = async () => {
if (!window.confirm('Are you sure you want to delete your review?')) return;
try {
await api.delete(`/plugins/${id}/reviews`);
fetchPlugin();
} catch {
alert('Failed to delete review.');
}
};
const submitReply = async (e: React.FormEvent, reviewId: number) => {
e.preventDefault();
if (!replyText.trim()) return;
setReplySubmitting(true);
try {
await api.post(`/plugins/${id}/reviews/${reviewId}/reply`, { reply: replyText.trim() });
setReplyOpen(null);
setReplyText('');
fetchPlugin();
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to post reply.');
} finally {
setReplySubmitting(false);
}
};
const resetReport = () => {
setReportOpen(false);
setReportSelected('');
setReportCustom('');
setReportDone(false);
};
const submitReport = async (e: React.FormEvent) => {
e.preventDefault();
const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected;
if (!reason) return;
setReportSubmitting(true);
try {
await api.post(`/plugins/${id}/report`, { reason });
setReportDone(true);
} catch {
alert('Failed to submit report. Please try again.');
} finally {
setReportSubmitting(false);
}
};
const resetReviewReport = () => {
setReviewReportOpen(null);
setReviewReportSelected('');
setReviewReportCustom('');
};
const submitReviewReport = async (e: React.FormEvent, reviewId: number) => {
e.preventDefault();
const reason = reviewReportSelected === 'Other' ? reviewReportCustom.trim() : reviewReportSelected;
if (!reason) return;
setReviewReportSubmitting(true);
try {
await api.post(`/plugins/${id}/reviews/${reviewId}/report`, { reason });
setReportedReviews(prev => new Set(prev).add(reviewId));
resetReviewReport();
alert('Thank you. The report has been submitted to moderators.');
} catch {
alert('Failed to report review. Please try again.');
} finally {
setReviewReportSubmitting(false);
}
};
const formatSize = (bytes?: number) => {
if (!bytes) return '';
if (bytes >= 1048576) return `(${(bytes / 1048576).toFixed(2)} MB)`;
if (bytes >= 1024) return `(${(bytes / 1024).toFixed(1)} KB)`;
return `(${bytes} B)`;
};
if (loading) {
return (
);
}
if (error || !plugin) {
return (
Plugin Not Found
{error || 'The requested plugin could not be found.'}
{t('common.back')}
);
}
const downloadLabel = (() => {
const sizeStr = plugin?.file_size ? ` ${formatSize(plugin.file_size)}` : '';
if (plugin.type === 'paid') {
if (!ownershipChecked) return 'Loading…';
if (ownsPlugin) {
if (plugin.is_preorder) {
return 'Pre-Ordered (Awaiting Release)';
}
return `Download ${sizeStr}`;
}
if (plugin.is_preorder) {
return 'Pre-Order Now';
}
return 'Purchase Plugin';
}
if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download';
return `Download ${sizeStr}`;
})();
const cleanDesc = currentDescription.replace(/[#*`_\[\]]/g, '').slice(0, 160).trim();
const productSchema = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareApplication",
"name": plugin.name,
"description": cleanDesc,
"operatingSystem": "Minecraft",
"applicationCategory": "GameApplication",
"softwareVersion": plugin.version || "1.0.0",
"fileSize": plugin.file_size ? `${plugin.file_size} B` : undefined,
"image": plugin.preview_path || "https://market.pumpkinmc.org/icon.png",
"offers": {
"@type": "Offer",
"price": plugin.type === 'paid' && plugin.price_cents ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
"aggregateRating": stats ? {
"@type": "AggregateRating",
"ratingValue": stats.avg,
"ratingCount": stats.total,
"bestRating": "5",
"worstRating": "1"
} : undefined,
"author": {
"@type": "Person",
"name": plugin.dev_name,
"url": `https://market.pumpkinmc.org/profile/${plugin.dev_name}`
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://market.pumpkinmc.org"
},
{
"@type": "ListItem",
"position": 2,
"name": plugin.category || "Plugins",
"item": `https://market.pumpkinmc.org/search?category=${encodeURIComponent(plugin.category || '')}`
},
{
"@type": "ListItem",
"position": 3,
"name": plugin.name,
"item": `https://market.pumpkinmc.org/plugin/${plugin.id}`
}
]
}
]
};
return (
{/* ── Pre-Order Banner ── */}
{plugin.is_preorder && (
Available for Pre-Order
{plugin.preorder_release_date && (
Expected: {formatDate(plugin.preorder_release_date)}
)}
{ownsPlugin ? (
You have successfully pre-ordered this plugin! Your license key is reserved, and you will receive an automatic email notification the moment the download is released.
) : (
Pre-order now to secure your license key and early access. You will receive an automatic email notification the moment the developer releases the downloadable version.
)}
)}
{/* ── Early Access Banner ── */}
{plugin.is_early_access && (
Early Access Plugin
This plugin is currently in Early Access . It is still under active development and may contain bugs or incomplete features.
Downloading it now helps support the developer and shape the future of the plugin!
)}
{/* ── Payment success banner ── */}
{showPaymentSuccess && (
Purchase successful!
You now own {plugin.name} . Click the download button to get your WASM file.
setShowPaymentSuccess(false)}
className="banner-close"
>×
)}
{/* HEADER */}
{!plugin.preview_path && plugin.name.charAt(0)}
{plugin.name}
by (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}
{/* SCREENSHOTS / MEDIA GALLERY */}
{((screenshots.length > 0) || plugin.youtube_video_url) && (
{mainScreenshot === 'video' ? (
) : (
{
const idx = screenshots.findIndex((s: any) => s.path === (mainScreenshot || screenshots[0]?.path));
openLightbox(idx >= 0 ? idx : 0);
}}
/>
)}
{plugin.youtube_video_url && (
setMainScreenshot('video')}
style={{ position: 'relative', overflow: 'hidden' }}
>
{getYoutubeVideoId(plugin.youtube_video_url) ? (
) : (
Video
)}
)}
{screenshots.map((shot: any) => (
setMainScreenshot(shot.path)}
/>
))}
)}
{Array.isArray(plugin.versions) && plugin.versions.length > 0 && (() => {
const latest = plugin.versions[0];
const isLong = Boolean(latest.release_notes && (latest.release_notes.length > 200 || latest.release_notes.split('\n').length > 4));
return (
What's New
v{latest.version}
{latest.created_at && (
{formatDate(latest.created_at)}
)}
{plugin.versions.length > 1 && (
setChangelogOpen(true)}
>
Version History ({plugin.versions.length})
)}
{latest.release_notes ? (
{latest.release_notes}
) : (
Bug fixes and performance improvements.
)}
{isLong && (
setChangelogOpen(true)}
>
View More
)}
);
})()}
{/* 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}
{formatDate(r.created_at)}
{[1, 2, 3, 4, 5].map(n => (
))}
{/* Developer Reply */}
{r.developer_reply && (
Developer Response
{formatDate(r.replied_at)}
)}
{/* Review Report & Reply Buttons */}
{reportedReviews.has(r.id) ? (
Report submitted
) : reviewReportOpen === r.id ? (
) : replyOpen === r.id ? (
submitReply(e, r.id)} style={{ width: '100%', maxWidth: '500px' }}>
setReplyText(e.target.value)}
rows={3}
autoFocus
style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}
/>
{ setReplyOpen(null); setReplyText(''); }}
style={{ padding: '4px 12px', fontSize: '0.75rem' }}
>
Cancel
{replySubmitting ? 'Replying...' : 'Post Reply'}
) : (
<>
{user && user.username === r.username ? (
Delete my review
) : user && (
setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
Report review
)}
{isOwner && !r.developer_reply && !replyOpen && (
setReplyOpen(r.id)}
style={{ fontSize: '0.7rem', color: 'var(--primary)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
Reply as developer
)}
>
)}
))}
{user ? (
hasReviewed ? (
Review posted
You've already reviewed this plugin. Thanks for the feedback!
Delete Review
) : (
Leave a Review
Your Rating
setReviewForm({ ...reviewForm, rating: val })} />
Your Experience
setReviewForm({ ...reviewForm, comment: e.target.value })}
required
/>
Post Review
)
) : (
Please login to rate this plugin.
)}
{/* SIDEBAR */}
{/* LIGHTBOX MODAL */}
{lightboxImage && (
e.stopPropagation()}>
×
{/* Left arrow — only shown when multiple screenshots exist */}
{screenshots.length > 1 && (
{ e.stopPropagation(); lightboxPrev(); }}
aria-label="Previous screenshot"
>
‹
)}
{/* Right arrow */}
{screenshots.length > 1 && (
{ e.stopPropagation(); lightboxNext(); }}
aria-label="Next screenshot"
>
›
)}
{/* Dot indicators */}
{screenshots.length > 1 && (
{screenshots.map((_: any, i: number) => (
{ e.stopPropagation(); openLightbox(i); }}
aria-label={`Go to screenshot ${i + 1}`}
/>
))}
)}
)}
{/* CHANGELOG MODAL */}
{changelogOpen && (
setChangelogOpen(false)} style={{ zIndex: 2000 }}>
e.stopPropagation()} style={{
maxWidth: '800px',
width: '90%',
maxHeight: '80vh',
backgroundColor: 'var(--surface)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
padding: 0,
overflow: 'hidden',
border: '1px solid var(--border)',
boxShadow: '0 20px 50px rgba(0,0,0,0.8)'
}}>
Update History
{(plugin.versions ?? []).map((v: any, idx: number, arr: any[]) => (
v{v.version}
Released {formatDate(v.created_at)}
{v.release_notes ? (
{v.release_notes}
) : (
No specific release notes for this update.
)}
))}
setChangelogOpen(false)}
className="btn btn-secondary"
style={{ padding: '8px 24px' }}
>
Close
)}
);
};
export default PluginDetail;