From 28c136a42b5eeb57c7af3ab25dac112a200f5b5d Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sun, 12 Apr 2026 16:12:13 +0200 Subject: [PATCH] chore: add review reports --- src/App.tsx | 2 + src/pages/AuthorProfilePage.tsx | 368 ++++++++++++++++++++++++++++++++ src/pages/PluginDetail.tsx | 114 +++++++++- 3 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 src/pages/AuthorProfilePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8d1c5c7..64e2de9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import RegisterPage from './pages/auth/RegisterPage'; import VerifyEmailPage from './pages/auth/VerifyEmailPage'; import CheckEmailPage from './pages/auth/CheckEmailPage'; import ProfilePage from './pages/ProfilePage'; +import AuthorProfilePage from './pages/AuthorProfilePage'; import SearchResultsPage from './pages/SearchResultsPage'; import DashboardOverview from './pages/dashboard/DashboardOverview'; import DashboardAudience from './pages/dashboard/DashboardAudience'; @@ -113,6 +114,7 @@ const App = () => ( }> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/AuthorProfilePage.tsx b/src/pages/AuthorProfilePage.tsx new file mode 100644 index 0000000..8dc99fa --- /dev/null +++ b/src/pages/AuthorProfilePage.tsx @@ -0,0 +1,368 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import api from '../api'; +import { useTranslation } from 'react-i18next'; +import SEO from '../components/SEO'; + +const AuthorProfilePage = () => { + const { username } = useParams(); + const { t, i18n } = useTranslation(); + const [author, setAuthor] = useState(null); + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + + // Fetch author public info + api.get(`/user/public/${username}`) + .then(res => { + setAuthor(res.data); + // Fetch author plugins + return api.get('/plugins', { params: { dev_name: username, sort: 'newest' } }); + }) + .then(res => { + setPlugins(Array.isArray(res.data) ? res.data : []); + }) + .catch(err => { + console.error('Fetch error:', err); + setError('User not found or failed to load plugins.'); + }) + .finally(() => { + setLoading(false); + }); + }, [username]); + + const getDescription = (translatedStr: string) => { + try { + const data = JSON.parse(translatedStr); + const currentLang = i18n.language.split('-')[0]; + return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.'; + } catch { + return 'No description available.'; + } + }; + + const getAccentColor = (name: string) => { + const colors = [ + '#4f7eff', '#ff6b6b', '#ffd166', '#06d6a0', + '#a855f7', '#f97316', '#06b6d4', '#ec4899', + ]; + let hash = 0; + for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); + return colors[Math.abs(hash) % colors.length]; + }; + + const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; originalDisplay: string } => { + const base = plugin.price_cents ?? 0; + const saleActive = plugin.sale_active && plugin.sale_discount_percent > 0; + if (saleActive) { + const saleCents = Math.round(base * (1 - plugin.sale_discount_percent / 100)); + return { + display: `€${(saleCents / 100).toFixed(2)}`, + isSale: true, + originalDisplay: `€${(base / 100).toFixed(2)}`, + }; + } + return { + display: `€${(base / 100).toFixed(2)}`, + isSale: false, + originalDisplay: '', + }; + }; + + if (loading) return

LOADING...

; + if (error || !author) return

{error || 'User not found'}

; + + return ( +
+ + +
+
+ {author.username.charAt(0).toUpperCase()} +
+
+

{author.username}

+

+ Member since {new Date(author.created_at).toLocaleDateString(undefined, { year: 'numeric', month: 'long' })} +

+
+
+ {plugins.length} Plugins Published +
+
+
+
+ +

Plugins by {author.username}

+ +
+ {plugins.map((plugin) => { + const accent = getAccentColor(plugin.name); + const desc = getDescription(plugin.translated_descriptions); + const initial = plugin.name.charAt(0).toUpperCase(); + const priceInfo = getEffectivePrice(plugin); + const isSale = plugin.type === 'paid' && priceInfo.isSale; + const imageUrl = plugin.preview_path; + + return ( + +
+
+ {imageUrl ? ( + {plugin.name} + ) : ( +
+ {initial} +
+ )} +
+ {plugin.category} + {plugin.type === 'free' ? ( + Free + ) : isSale ? ( + + {priceInfo.originalDisplay} + {priceInfo.display} + -{plugin.sale_discount_percent}% + + ) : ( + {priceInfo.display} + )} +
+
+

{plugin.name}

+

by {plugin.dev_name}

+

{desc}

+
+ + + + + {(plugin.downloads ?? 0).toLocaleString()} + + {plugin.version && ( + + v{plugin.version} + + )} +
+
+ {isSale && ( +
πŸ”₯ Sale β€” {plugin.sale_discount_percent}% off
+ )} +
+
+ + ); + })} + + {plugins.length === 0 && ( +
+

This author hasn't published any plugins yet.

+
+ )} +
+ + +
+ ); +}; + +export default AuthorProfilePage; diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index 5a9ed43..3f0b440 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -9,14 +9,21 @@ import SEO from '../components/SEO'; const REPORT_REASONS = [ 'Malware or malicious code', -'Stolen or plagiarised content', -'Misleading description', -'Broken / non-functional', -'Inappropriate content', -'Spam or fake listing', -'Other', + 'Stolen or plagiarised content', + 'Misleading description', + 'Broken / non-functional', + 'Inappropriate content', + 'Spam or fake listing', + 'Other', ]; +const 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 }) => ( @@ -76,7 +83,7 @@ const PluginDetail = () => { const [lightboxIndex, setLightboxIndex] = useState(0); const [selectedLanguage] = useState('en'); const [currentDescription, setCurrentDescription] = useState(''); - const [setLoading] = useState(true); + const [loading, setLoading] = useState(true); // Ownership const [ownsPlugin, setOwnsPlugin] = useState(false); @@ -92,6 +99,13 @@ const PluginDetail = () => { const [reportSubmitting, setReportSubmitting] = useState(false); const [reportDone, setReportDone] = useState(false); + // Review Report State + const [reviewReportOpen, setReviewReportOpen] = useState(null); + const [reviewReportSelected, setReviewReportSelected] = useState(''); + const [reviewReportCustom, setReviewReportCustom] = useState(''); + const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false); + const [reportedReviews, setReportedReviews] = useState>(new Set()); + // ── Detect ?payment=success and strip from URL ─────────────────────────── useEffect(() => { const params = new URLSearchParams(location.search); @@ -290,6 +304,28 @@ const PluginDetail = () => { 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

LOADING...

; const formatSize = (bytes: number) => { @@ -379,7 +415,7 @@ const PluginDetail = () => {

{plugin.name}

-

by {plugin.dev_name}

+

by (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}

@@ -474,6 +510,63 @@ const PluginDetail = () => { ))}

{r.comment}

+ + {/* Review Report Button/Form */} +
+ {reportedReviews.has(r.id) ? ( +

βœ“ Report submitted

+ ) : reviewReportOpen === r.id ? ( +
submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px' }}> + +
+ {REVIEW_REPORT_REASONS.map(reason => ( + + ))} +
+ {reviewReportSelected === 'Other' && ( +