1281 lines
47 KiB
TypeScript
1281 lines
47 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
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';
|
||
|
||
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 getYoutubeEmbedUrl = (url: string) => {
|
||
if (!url) return '';
|
||
const videoId = getYoutubeVideoId(url);
|
||
return videoId ? `https://www.youtube.com/embed/${videoId}` : url;
|
||
};
|
||
|
||
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 StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
|
||
<div className="star-rating-container">
|
||
{[...Array(max)].map((_, i) => (
|
||
<span
|
||
key={i}
|
||
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
|
||
onClick={() => onRate && onRate(i + 1)}
|
||
>
|
||
{i < rating ? '★' : '☆'}
|
||
</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 { 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 [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 [selectedLanguage] = useState('en');
|
||
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 = () => {
|
||
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);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
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);
|
||
setCurrentDescription(
|
||
translations[selectedLanguage] ||
|
||
translations['en'] ||
|
||
Object.values(translations)[0] as string ||
|
||
''
|
||
);
|
||
} catch {
|
||
setCurrentDescription('Description not available.');
|
||
}
|
||
} else {
|
||
setCurrentDescription('Description not available.');
|
||
}
|
||
}, [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);
|
||
}, [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) {
|
||
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);
|
||
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 (err) {
|
||
console.error('Failed to delete review', err);
|
||
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) {
|
||
console.error('Failed to submit reply', err);
|
||
alert('Failed to submit reply');
|
||
} finally {
|
||
setReplySubmitting(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);
|
||
setReportSelected('');
|
||
setReportCustom('');
|
||
} catch {
|
||
alert('Failed to submit report. Please try again.');
|
||
} finally {
|
||
setReportSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const resetReport = () => {
|
||
setReportOpen(false);
|
||
setReportSelected('');
|
||
setReportCustom('');
|
||
};
|
||
|
||
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();
|
||
} catch {
|
||
alert('Failed to submit report. Please try again.');
|
||
} finally {
|
||
setReviewReportSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const resetReviewReport = () => {
|
||
setReviewReportOpen(null);
|
||
setReviewReportSelected('');
|
||
setReviewReportCustom('');
|
||
};
|
||
|
||
if (!plugin) return <div className="container"><h2>LOADING...</h2></div>;
|
||
|
||
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)`;
|
||
};
|
||
|
||
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 === '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>
|
||
|
||
{/* ── Early Access Banner ── */}
|
||
{plugin.is_early_access && (
|
||
<div className="early-access-banner" style={{ marginBottom: '1.5rem' }}>
|
||
<div className="early-access-icon">⏳</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">🎉</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' ? (
|
||
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: '8px', marginBottom: '15px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||
<iframe
|
||
src={getYoutubeEmbedUrl(plugin.youtube_video_url)}
|
||
title="YouTube video player"
|
||
frameBorder="0"
|
||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||
allowFullScreen
|
||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 0 }}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<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>
|
||
)}
|
||
|
||
{/* DESCRIPTION */}
|
||
<div className="markdown-content">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown>
|
||
</div>
|
||
|
||
<hr className="divider" />
|
||
|
||
{/* REVIEWS */}
|
||
<section className="reviews-section">
|
||
<span className="reviews-section-label">Ratings & 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'}`}>★</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'}`}>★</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)', textTransform: 'uppercase' }}>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 }}>✓ 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' }}>
|
||
🗑 Delete my review
|
||
</button>
|
||
) : user && (
|
||
<button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}>
|
||
🚩 Report review
|
||
</button>
|
||
)}
|
||
|
||
{isOwner && !r.developer_reply && !replyOpen && (
|
||
<button
|
||
className="btn-report"
|
||
onClick={() => setReplyOpen(r.id)}
|
||
style={{ fontSize: '0.7rem', color: 'var(--primary)' }}
|
||
>
|
||
💬 Reply as developer
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{user ? (
|
||
hasReviewed ? (
|
||
<div className="review-already-posted">
|
||
<span className="review-already-icon">✓</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: '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>
|
||
Purchased
|
||
</div>
|
||
) : plugin.type === 'paid' ? (
|
||
<div>
|
||
{(() => {
|
||
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
|
||
type="text"
|
||
value={couponCode}
|
||
onChange={e => setCouponCode(e.target.value.toUpperCase())}
|
||
placeholder="PROMOCODE"
|
||
disabled={validatingCoupon || appliedCoupon !== null}
|
||
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)}>
|
||
🚩 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">✓ 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}>
|
||
×
|
||
</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"
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
|
||
{/* Right arrow */}
|
||
{screenshots.length > 1 && (
|
||
<button
|
||
className="lightbox-arrow lightbox-arrow-right"
|
||
onClick={(e) => { e.stopPropagation(); lightboxNext(); }}
|
||
aria-label="Next screenshot"
|
||
>
|
||
›
|
||
</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)', textTransform: 'uppercase', letterSpacing: '1px', 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]}>{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;
|