Market-Frontend/src/pages/PluginDetail.tsx
2026-08-30 11:27:13 +02:00

1466 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from 'lucide-react';
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 <span>{children}</span>;
}
const isExternal = href.startsWith('http://') || href.startsWith('https://');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer nofollow' : undefined}
{...props}
>
{children}
</a>
);
},
};
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
<div className="star-rating-container" style={{ display: 'flex', gap: 2 }}>
{[...Array(max)].map((_, i) => (
<span
key={i}
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
onClick={() => onRate && onRate(i + 1)}
style={{ display: 'inline-flex', alignItems: 'center' }}
>
<Star size={16} fill={i < rating ? 'currentColor' : 'none'} />
</span>
))}
</div>
);
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 (
<div className={className}>
<p style={{ margin: 0, whiteSpace: 'pre-wrap', overflowWrap: 'break-word', wordBreak: 'break-word', ...pStyle }}>
{displayText}
</p>
{shouldTruncate && (
<button
onClick={() => 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'}
</button>
)}
</div>
);
};
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 (
<div className="price-container">
<span className="currency-symbol">€</span>
<span className="price-dollars">{euros}</span>
<span className="price-decimal">.</span>
<span className="price-cents">{remainder}</span>
</div>
);
};
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 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<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
const [adShown, setAdShown] = useState(false);
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number>(0);
const [currentDescription, setCurrentDescription] = useState('');
// Coupon state
const [couponCode, setCouponCode] = useState('');
const [appliedCoupon, setAppliedCoupon] = useState<string | null>(null);
const [couponDiscount, setCouponDiscount] = useState<number>(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<string>('');
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<number | null>(null);
const [reviewReportSelected, setReviewReportSelected] = useState('');
const [reviewReportCustom, setReviewReportCustom] = useState('');
const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false);
const [replyOpen, setReplyOpen] = useState<number | null>(null);
const [replyText, setReplyText] = useState('');
const [replySubmitting, setReplySubmitting] = useState(false);
const isOwner = user && plugin && user.id === plugin.dev_id;
const [reportedReviews, setReportedReviews] = useState<Set<number>>(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 = JSON.parse(plugin.translated_descriptions);
const activeLang = (i18n.language || 'en').slice(0, 2);
setCurrentDescription(
translations[activeLang] ||
translations['en'] ||
(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) => {
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 (!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 (
<div className="container">
<div className="loading-state" style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !plugin) {
return (
<div className="container">
<div className="empty-state" style={{ minHeight: '60vh', textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Plugin Not Found</h2>
<p style={{ color: 'var(--text-muted)', margin: '1rem 0 1.5rem' }}>
{error || 'The requested plugin could not be found.'}
</p>
<Link to="/" className="btn btn-secondary">
{t('common.back')}
</Link>
</div>
</div>
);
}
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 / 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 (
<div className="container">
<SEO
title={plugin.name}
description={cleanDesc || `Download ${plugin.name} by ${plugin.dev_name} on Pumpkin Market.`}
keywords={`${plugin.name}, minecraft plugin, ${plugin.category || ''}, ${plugin.keywords || ''}`}
ogType="product"
ogImage={plugin.preview_path || "/icon.png"}
/>
<script type="application/ld+json">
{JSON.stringify(productSchema)}
</script>
{/* ── Pre-Order Banner ── */}
{plugin.is_preorder && (
<div style={{
marginBottom: '1.5rem',
padding: '1.25rem 1.5rem',
background: 'linear-gradient(135deg, rgba(249,115,22,0.12) 0%, rgba(249,115,22,0.04) 100%)',
border: '1px solid rgba(249,115,22,0.35)',
borderRadius: 12,
display: 'flex',
alignItems: 'flex-start',
gap: '1rem',
boxShadow: '0 4px 20px rgba(249,115,22,0.08)'
}}>
<div style={{
width: 38,
height: 38,
borderRadius: 8,
background: 'rgba(249,115,22,0.2)',
color: '#f97316',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<Clock size={20} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#f97316', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
Available for Pre-Order
{plugin.preorder_release_date && (
<span style={{ fontSize: '0.75rem', fontWeight: 600, background: 'rgba(249,115,22,0.2)', color: '#f97316', padding: '2px 8px', borderRadius: 4 }}>
Expected: {formatDate(plugin.preorder_release_date)}
</span>
)}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted, #94a3b8)', lineHeight: 1.5 }}>
{ownsPlugin ? (
<span style={{ color: '#4ade80', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
<Check size={16} color="#4ade80" /> 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.
</span>
) : (
<span>
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.
</span>
)}
</div>
</div>
</div>
)}
{/* ── Early Access Banner ── */}
{plugin.is_early_access && (
<div className="early-access-banner" style={{ marginBottom: '1.5rem' }}>
<div className="early-access-icon"><Clock size={18} /></div>
<div className="early-access-content">
<div className="early-access-title">Early Access Plugin</div>
<div className="early-access-text">
This plugin is currently in <strong>Early Access</strong>. 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!
</div>
</div>
</div>
)}
{/* ── Payment success banner ── */}
{showPaymentSuccess && (
<div className="payment-success-banner">
<span className="success-icon"><CheckCircle2 size={24} color="#10b981" /></span>
<div className="banner-content">
<div className="banner-title">
Purchase successful!
</div>
<div className="banner-text">
You now own <strong>{plugin.name}</strong>. Click the download button to get your WASM file.
</div>
</div>
<button
onClick={() => setShowPaymentSuccess(false)}
className="banner-close"
>×</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 className="plugin-header-info">
<h1 className="plugin-title">{plugin.name}</h1>
<p className="dev-name">by <Link to={`/profile/${plugin.dev_name}`} style={{ color: 'inherit', textDecoration: 'none' }} onMouseEnter={e => (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}</Link></p>
</div>
</div>
<div className="plugin-detail-layout">
<div className="plugin-main-content">
{/* SCREENSHOTS / MEDIA GALLERY */}
{((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && (
<div className="screenshot-gallery">
{mainScreenshot === 'video' ? (
<PluginVideoPlayer url={plugin.youtube_video_url} />
) : (
<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);
}}
/>
)}
<div className="thumbnail-strip">
{plugin.youtube_video_url && (
<div
className={`thumbnail ${mainScreenshot === 'video' ? 'active' : ''}`}
onClick={() => setMainScreenshot('video')}
style={{ position: 'relative', overflow: 'hidden' }}
>
{getYoutubeVideoId(plugin.youtube_video_url) ? (
<img
src={`https://img.youtube.com/vi/${getYoutubeVideoId(plugin.youtube_video_url)}/hqdefault.jpg`}
alt="Video Thumbnail"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#000', color: '#fff', fontSize: '10px' }}>Video</div>
)}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}>
<svg viewBox="0 0 24 24" width="32" height="32" fill="var(--primary, #ff6b00)">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
)}
{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>
)}
{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 (
<div className="plugin-recent-update">
<div className="plugin-recent-update-header">
<div className="plugin-recent-update-title-group">
<h3 className="plugin-recent-update-title">
<Sparkles size={16} color="var(--primary)" />
What's New
</h3>
<span className="plugin-recent-update-badge">
v{latest.version}
</span>
{latest.created_at && (
<span className="plugin-recent-update-date">
{formatDate(latest.created_at)}
</span>
)}
</div>
{plugin.versions.length > 1 && (
<button
type="button"
className="plugin-recent-update-history-btn"
onClick={() => setChangelogOpen(true)}
>
<History size={13} />
<span>Version History ({plugin.versions.length})</span>
</button>
)}
</div>
<div className={`plugin-recent-update-body markdown-content ${isLong ? 'is-clamped' : ''}`}>
{latest.release_notes ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>
{latest.release_notes}
</ReactMarkdown>
) : (
<p className="plugin-recent-update-empty">
Bug fixes and performance improvements.
</p>
)}
</div>
{isLong && (
<button
type="button"
className="plugin-recent-update-more-btn"
onClick={() => setChangelogOpen(true)}
>
<span>View More</span>
<ChevronRight size={14} />
</button>
)}
</div>
);
})()}
{/* DESCRIPTION */}
<div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{currentDescription}</ReactMarkdown>
</div>
<hr className="divider" />
{/* REVIEWS */}
<section className="reviews-section">
<span className="reviews-section-label">Ratings &amp; 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'}`}>
<Star size={14} fill={n <= Math.round(Number(stats.avg)) ? 'currentColor' : 'none'} />
</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">{formatDate(r.created_at)}</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'}`}>
<Star size={12} fill={n <= r.rating ? 'currentColor' : 'none'} />
</span>
))}
</div>
<ExpandableText className="review-item-body" text={r.comment} limit={400} />
{/* Developer Reply */}
{r.developer_reply && (
<div className="developer-reply" style={{
marginTop: '1rem',
padding: '1rem',
background: 'rgba(255, 117, 24, 0.05)',
borderLeft: '2px solid var(--primary)',
borderRadius: '4px',
overflowWrap: 'break-word',
wordBreak: 'break-word'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--primary)' }}>Developer Response</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{formatDate(r.replied_at)}</span>
</div>
<ExpandableText
text={r.developer_reply}
limit={300}
pStyle={{ fontSize: '0.9rem', color: 'var(--text)', lineHeight: 1.6 }}
/>
</div>
)}
{/* Review Report & Reply Buttons */}
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
{reportedReviews.has(r.id) ? (
<p style={{ fontSize: '0.75rem', color: 'var(--success)', margin: 0, display: 'flex', alignItems: 'center', gap: 4 }}>
<Check size={13} /> Report submitted
</p>
) : reviewReportOpen === r.id ? (
<form onSubmit={(e) => submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}>
<label className="report-form-label">Reason for reporting this review</label>
<div className="report-reasons" style={{ transform: 'scale(0.9)', transformOrigin: 'left' }}>
{REVIEW_REPORT_REASONS.map(reason => (
<button
key={reason}
type="button"
className={`report-reason-chip ${reviewReportSelected === reason ? 'selected' : ''}`}
onClick={() => { setReviewReportSelected(reason); setReviewReportCustom(''); }}
>
{reason}
</button>
))}
</div>
{reviewReportSelected === 'Other' && (
<textarea
className="report-textarea"
placeholder="Describe the issue…"
value={reviewReportCustom}
onChange={e => setReviewReportCustom(e.target.value)}
rows={2}
autoFocus
style={{ fontSize: '0.8rem' }}
/>
)}
<div className="report-form-actions">
<button type="button" className="btn-report-cancel" onClick={resetReviewReport} style={{ padding: '4px 8px', fontSize: '0.7rem' }}>
Cancel
</button>
<button
type="submit"
className="btn-report-submit"
disabled={
reviewReportSubmitting ||
!reviewReportSelected ||
(reviewReportSelected === 'Other' && !reviewReportCustom.trim())
}
style={{ padding: '4px 8px', fontSize: '0.7rem' }}
>
{reviewReportSubmitting ? 'Sending…' : 'Submit'}
</button>
</div>
</form>
) : replyOpen === r.id ? (
<form onSubmit={(e) => submitReply(e, r.id)} style={{ width: '100%', maxWidth: '500px' }}>
<textarea
className="report-textarea"
placeholder="Write your response to this review..."
value={replyText}
onChange={e => setReplyText(e.target.value)}
rows={3}
autoFocus
style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
className="btn-report-cancel"
onClick={() => { setReplyOpen(null); setReplyText(''); }}
style={{ padding: '4px 12px', fontSize: '0.75rem' }}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={replySubmitting || !replyText.trim()}
style={{ padding: '4px 12px', fontSize: '0.75rem', height: 'auto' }}
>
{replySubmitting ? 'Replying...' : 'Post Reply'}
</button>
</div>
</form>
) : (
<>
{user && user.username === r.username ? (
<button className="btn-report" onClick={deleteReview} style={{ fontSize: '0.7rem', color: '#ff4d4f', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Trash2 size={12} /> Delete my review
</button>
) : user && (
<button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Flag size={12} /> Report review
</button>
)}
{isOwner && !r.developer_reply && !replyOpen && (
<button
className="btn-report"
onClick={() => setReplyOpen(r.id)}
style={{ fontSize: '0.7rem', color: 'var(--primary)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<MessageSquare size={12} /> Reply as developer
</button>
)}
</>
)}
</div>
</div>
))}
</div>
{user ? (
hasReviewed ? (
<div className="review-already-posted">
<span className="review-already-icon"><Check size={14} /></span>
<div style={{ flex: 1 }}>
<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>
<button
onClick={deleteReview}
style={{
background: 'rgba(255, 77, 79, 0.1)',
color: '#ff4d4f',
border: 'none',
padding: '8px 12px',
borderRadius: '6px',
fontSize: '0.8rem',
cursor: 'pointer',
fontWeight: '600'
}}
>
Delete Review
</button>
</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>
{/* SIDEBAR */}
<aside className="plugin-sidebar">
<div className="sidebar-widget">
{/* Price / owned indicator */}
<div className="price-hero-section">
{plugin.type === 'paid' && ownsPlugin ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: plugin.is_preorder ? '#f97316' : 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
<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>
{plugin.is_preorder ? 'Pre-Ordered (Awaiting Release)' : 'Purchased'}
</div>
) : plugin.type === 'paid' ? (
<div>
{plugin.is_preorder && (
<span style={{ display: 'inline-block', fontSize: '0.68rem', fontWeight: 800, background: 'rgba(249,115,22,0.15)', color: '#f97316', border: '1px solid rgba(249,115,22,0.3)', borderRadius: 4, padding: '1px 6px', marginBottom: '0.35rem' }}>
PRE-ORDER
</span>
)}
{(() => {
const base = plugin.price_cents;
const salePrice = plugin.sale_active && plugin.sale_discount_percent > 0
? Math.round(base * (1 - plugin.sale_discount_percent / 100))
: base;
const finalPrice = couponDiscount > 0
? Math.round(salePrice * (1 - couponDiscount / 100))
: salePrice;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
<PriceDisplay cents={finalPrice} />
{(plugin.sale_active && plugin.sale_discount_percent > 0) || couponDiscount > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', 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>
{plugin.sale_active && plugin.sale_discount_percent > 0 && (
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.1) ', border: '1px solid rgba(255,107,107,0.2) ', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px' }}>
-{plugin.sale_discount_percent}% SALE
</span>
)}
{couponDiscount > 0 && (
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(74,222,128,0.1) ', border: '1px solid rgba(74,222,128,0.2) ', color: '#4ade80', borderRadius: 4, padding: '1px 6px' }}>
-{couponDiscount}% COUPON
</span>
)}
</div>
) : null}
</div>
);
})()}
</div>
) : (
<div className="price-free">{plugin.type === 'adwall' ? 'AD SUPPORTED' : 'FREE'}</div>
)}
</div>
{plugin.type === 'paid' && !ownsPlugin && (
<div style={{ marginBottom: '1.25rem', marginTop: '1rem', borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '0.5rem', letterSpacing: '0.05em' }}>Have a promo code?</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
id="detailPromoCode"
name="promoCode"
type="text"
value={couponCode}
onChange={e => setCouponCode(e.target.value.toUpperCase())}
placeholder="PROMOCODE"
disabled={validatingCoupon || appliedCoupon !== null}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '0.85rem',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
borderRadius: '6px',
border: '1px solid var(--border)',
backgroundColor: 'rgba(0,0,0,0.2)',
color: 'var(--text)',
outline: 'none'
}}
/>
{appliedCoupon ? (
<button
onClick={handleClearCoupon}
className="btn btn-secondary"
style={{ padding: '8px 16px', fontSize: '0.85rem', borderRadius: '6px', cursor: 'pointer', height: 'auto', display: 'flex', alignItems: 'center' }}
>
Clear
</button>
) : (
<button
onClick={handleApplyCoupon}
disabled={validatingCoupon || !couponCode.trim()}
className="btn btn-download-main"
style={{ width: 'auto', padding: '8px 16px', fontSize: '0.85rem', borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
{validatingCoupon ? '…' : 'Apply'}
</button>
)}
</div>
{couponMessage && (
<p style={{
fontSize: '0.78rem',
marginTop: '6px',
color: couponMessage.isError ? '#ef4444' : '#4ade80',
fontWeight: 500
}}>
{couponMessage.text}
</p>
)}
</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>
{plugin.created_at && (
<div className="stat-item">Released: <strong>{formatDate(plugin.created_at)}</strong></div>
)}
<div className="stat-item">Category: <strong>{plugin.category}</strong></div>
{plugin.version && (
<div className="stat-item" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Version: <strong>{plugin.version}</strong></span>
{Array.isArray(plugin.versions) && plugin.versions.length > 0 && (
<button
onClick={() => setChangelogOpen(true)}
style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'var(--text)',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.7rem',
cursor: 'pointer',
fontWeight: 600,
marginLeft: '8px'
}}
>
Update History
</button>
)}
</div>
)}
<div className="divider-sm" />
{user && !reportDone && (
<>
{!reportOpen ? (
<button className="btn-report" onClick={() => setReportOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Flag size={12} /> 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" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Check size={14} /> 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}>
&times;
</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>
)}
{/* CHANGELOG MODAL */}
{changelogOpen && (
<div className="lightbox-overlay" onClick={() => setChangelogOpen(false)} style={{ zIndex: 2000 }}>
<div className="lightbox-content" onClick={(e) => 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)'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'linear-gradient(to right, rgba(255, 117, 24, 0.05), transparent)'
}}>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: 'var(--primary)', fontWeight: 800 }}>Update History</h2>
</div>
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>
{Array.isArray(plugin.versions) && plugin.versions.map((v: any, idx: number) => (
<div key={v.id} style={{
marginBottom: idx === plugin.versions.length - 1 ? 0 : '32px',
paddingBottom: idx === plugin.versions.length - 1 ? 0 : '32px',
borderBottom: idx === plugin.versions.length - 1 ? 'none' : '1px solid var(--border)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<span style={{
color: 'var(--primary)',
fontSize: '1.1rem',
fontWeight: 800,
fontFamily: 'var(--font-mono)'
}}>
v{v.version}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Released {formatDate(v.created_at)}
</span>
</div>
<div className="markdown-content" style={{ color: 'var(--text)', fontSize: '0.95rem', lineHeight: 1.6 }}>
{v.release_notes ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{v.release_notes}</ReactMarkdown>
) : (
<p style={{ fontStyle: 'italic', color: 'var(--text-muted)', opacity: 0.5 }}>No specific release notes for this update.</p>
)}
</div>
</div>
))}
</div>
<div style={{ padding: '16px 24px', borderTop: '1px solid var(--border)', background: 'rgba(0,0,0,0.1)', textAlign: 'right' }}>
<button
onClick={() => setChangelogOpen(false)}
className="btn btn-secondary"
style={{ padding: '8px 24px' }}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PluginDetail;