From 4de3327b20f27c02d5f53abd96c9d2d1a8bdd378 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sun, 26 Apr 2026 10:15:33 +0200 Subject: [PATCH] added more things :D --- src/App.css | 497 +++++++++++++++++++- src/App.tsx | 31 +- src/components/PluginCard.tsx | 175 +++++++ src/pages/Home.tsx | 306 +----------- src/pages/PluginDetail.tsx | 146 +++++- src/pages/SearchResultsPage.tsx | 227 +++++---- src/pages/dashboard/plugin/AddPlugin.tsx | 22 +- src/pages/dashboard/plugin/ManagePlugin.tsx | 1 + src/pages/dashboard/plugin/StoreListing.tsx | 17 + 9 files changed, 1001 insertions(+), 421 deletions(-) create mode 100644 src/components/PluginCard.tsx diff --git a/src/App.css b/src/App.css index f9e9fa8..44a477a 100644 --- a/src/App.css +++ b/src/App.css @@ -220,6 +220,27 @@ h3 { border-color: var(--primary); } +.nav-search-right { + margin-right: 1rem; + width: 250px; +} + +.nav-search-right input { + width: 100%; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: 0.85rem; + outline: none; + transition: border-color 0.2s; +} + +.nav-search-right input:focus { + border-color: var(--primary); +} + .nav-actions { display: flex; align-items: center; @@ -811,13 +832,425 @@ h3 { border-color: var(--primary); } -.error-message { - color: #ff4444; - /* A distinct red for errors */ - text-align: center; - margin: 2rem 0; - font-size: 1.1rem; - font-weight: 600; +/* Search Results Page Enhancement */ +.search-page-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 3rem; + align-items: flex-start; +} + +.search-sidebar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 2rem; + position: sticky; + top: 120px; +} + +.filter-section { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.filter-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0; +} + +.btn-text-only { + background: none; + border: none; + color: var(--primary); + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + text-transform: uppercase; + padding: 0; +} + +.btn-text-only:hover { + text-decoration: underline; +} + +.filter-group { + margin-bottom: 2rem; +} + +.filter-group label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.75rem; + letter-spacing: 0.5px; +} + +.filter-group select, +.filter-group input { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; +} + +.filter-group select:focus, +.filter-group input:focus { + border-color: var(--primary); +} + +.price-range-inputs { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.price-range-inputs span { + color: var(--border); +} + +.search-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.search-title { + margin: 0; + font-size: 2.5rem; + display: flex; + align-items: baseline; + gap: 1rem; +} + +.results-count { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8rem 0; + color: var(--text-muted); +} + +.loading-state .spinner { + width: 40px; + height: 40px; + margin-bottom: 1.5rem; + border-width: 3px; +} + +/* --- Unified Plugin Card (v2) --- */ +.plugin-card-link { + text-decoration: none; + display: block; +} + +.plugin-card-v2 { + position: relative; + border-radius: 10px; + overflow: hidden; + background: #16191f; + border: 1px solid rgba(255, 255, 255, 0.07); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + will-change: transform; + height: 100%; + display: flex; + flex-direction: column; +} + +.plugin-card-v2:hover { + transform: translateY(-3px); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); + border-color: rgba(255, 255, 255, 0.14); +} + +.pcv2-preview { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + background: #0d0f12; +} + +.pcv2-preview-img, +.pcv2-slideshow-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.35s ease, opacity 0.4s ease-in-out; +} + +.plugin-card-v2:hover .pcv2-preview-img { + transform: scale(1.04); +} + +.pcv2-preview-fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.8rem; + font-weight: 800; + letter-spacing: -0.04em; + color: rgba(255, 255, 255, 0.9); + font-family: system-ui; + user-select: none; +} + +.pcv2-preview-fade { + position: absolute; + inset: 0; + background: linear-gradient(to bottom, transparent 30%, rgba(13,15,18,0.55) 65%, rgba(13,15,18,0.96) 100%); + pointer-events: none; + z-index: 1; +} + +.pcv2-category-badge { + position: absolute; + top: 10px; + left: 10px; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + background: rgba(13,15,18,0.72); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.75); + padding: 0.2rem 0.5rem; + border-radius: 4px; + z-index: 2; +} + +.pcv2-price-badge { + position: absolute; + top: 10px; + right: 10px; + font-size: 0.7rem; + font-weight: 700; + padding: 0.2rem 0.55rem; + border-radius: 4px; + backdrop-filter: blur(6px); + display: flex; + align-items: center; + gap: 0.35rem; + z-index: 2; +} + +.pcv2-price-badge.free { + background: rgba(6, 214, 160, 0.18); + border: 1px solid rgba(6, 214, 160, 0.35); + color: #06d6a0; +} + +.pcv2-price-badge.paid { + background: rgba(255, 209, 102, 0.16); + border: 1px solid rgba(255, 209, 102, 0.35); + color: #ffd166; +} + +.pcv2-price-badge.sale { + background: rgba(255, 107, 107, 0.18); + border: 1px solid rgba(255, 107, 107, 0.4); + color: #ff6b6b; +} + +.pcv2-price-original { + text-decoration: line-through; + opacity: 0.55; + font-weight: 500; +} + +.pcv2-sale-pct { + font-size: 0.58rem; + font-weight: 800; + background: rgba(255, 107, 107, 0.25); + border-radius: 3px; + padding: 1px 4px; + letter-spacing: 0.04em; +} + +.pcv2-info { + padding: 0.85rem 1rem 1rem; + flex-grow: 1; +} + +.pcv2-name { + font-size: 0.95rem; + font-weight: 700; + color: #f0f0f0; + margin: 0 0 0.15rem; + font-family: system-ui; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pcv2-dev { + font-size: 0.72rem; + color: #5a6070; + margin: 0 0 0.55rem; +} + +.pcv2-desc { + font-size: 0.77rem; + color: #8b909a; + line-height: 1.55; + margin: 0 0 0.75rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.pcv2-footer { + display: flex; + align-items: center; + gap: 0.9rem; + padding-top: 0.65rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.pcv2-stat { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + color: #5a6070; +} + +.pcv2-stat svg { + opacity: 0.6; +} + +.pcv2-accent-bar { + position: absolute; bottom: 0; left: 0; right: 0; + height: 2px; opacity: 0; + transition: opacity 0.2s ease; +} + +.plugin-card-v2:hover .pcv2-accent-bar { + opacity: 1; +} + +.pcv2-sale-strip { + background: linear-gradient(90deg, rgba(255, 107, 107, 0.15), rgba(255, 107, 107, 0.05)); + border-top: 1px solid rgba(255, 107, 107, 0.2); + padding: 0.3rem 1rem; + font-size: 0.65rem; + font-weight: 700; + color: #ff6b6b; + letter-spacing: 0.06em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Slideshow */ +.pcv2-slideshow { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; +} + +.pcv2-slideshow-img { + position: absolute; + inset: 0; + opacity: 0; +} + +.pcv2-slideshow-img.active { + opacity: 1; +} + +.pcv2-slideshow-progress { + position: absolute; + bottom: 12px; + left: 0; + right: 0; + display: flex; + justify-content: center; + gap: 4px; + z-index: 3; +} + +.pcv2-progress-dot { + width: 12px; + height: 2px; + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; + transition: background 0.3s; +} + +.pcv2-progress-dot.active { + background: var(--primary, #FF7518); + box-shadow: 0 0 8px var(--primary); +} + +@media (max-width: 768px) { + .pcv2-preview-fallback { + font-size: 2rem; + } +} + + +@media (max-width: 1024px) { + .search-page-layout { + grid-template-columns: 1fr; + gap: 2rem; + } + + .search-sidebar { + position: static; + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + padding: 1.5rem; + } + + .filter-section { + width: 100%; + margin-bottom: 0.5rem; + } + + .filter-group { + margin-bottom: 0; + flex: 1; + min-width: 200px; + } + + .search-header { + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; + } } @@ -949,6 +1382,56 @@ h3 { color: var(--text); } +/* --- Early Access Banner --- */ +.early-access-banner { + background: rgba(255, 117, 24, 0.08); + border: 1px solid rgba(255, 117, 24, 0.3); + border-left: 4px solid var(--primary, #FF7518); + border-radius: 8px; + padding: 1.25rem; + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.early-access-icon { + font-size: 1.5rem; + line-height: 1; +} + +.early-access-content { + flex: 1; +} + +.early-access-title { + font-weight: 700; + color: var(--primary, #FF7518); + margin-bottom: 0.25rem; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.early-access-text { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.5; + margin: 0; +} + +.pcv2-badge-ea { + background: var(--primary, #FF7518); + color: white; + font-size: 0.6rem; + font-weight: 800; + padding: 2px 5px; + border-radius: 3px; + text-transform: uppercase; + margin-left: 0.5rem; + vertical-align: middle; +} + + @media (max-width: 480px) { .payment-success-banner { padding: 1rem; diff --git a/src/App.tsx b/src/App.tsx index 6231a5b..1843044 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -212,18 +212,31 @@ const Navbar = ({ user }: any) => { {user && setIsMenuOpen(false)}>{t('dashboard')}} {user && user.role === 'admin' && setIsMenuOpen(false)}>Admin} -
- setSearchQuery(e.target.value)} - onKeyDown={handleSearch} - /> -
+ + {!user && ( +
+ setSearchQuery(e.target.value)} + onKeyDown={handleSearch} + /> +
+ )} +
{user ? ( <> +
+ setSearchQuery(e.target.value)} + onKeyDown={handleSearch} + /> +
setIsMenuOpen(false)}>{user.username} ) : ( diff --git a/src/components/PluginCard.tsx b/src/components/PluginCard.tsx new file mode 100644 index 0000000..d59461d --- /dev/null +++ b/src/components/PluginCard.tsx @@ -0,0 +1,175 @@ +import { useState, useRef, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +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: '', + }; +}; + +const PluginCard = ({ plugin }: { plugin: any }) => { + const { i18n } = useTranslation(); + const [currentIndex, setCurrentIndex] = useState(0); + const [isHovering, setIsHovering] = useState(false); + const timerRef = useRef(null); + const cycleRef = useRef(null); + + const accent = useMemo(() => getAccentColor(plugin.name), [plugin.name]); + + 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 priceInfo = useMemo(() => getEffectivePrice(plugin), [plugin]); + const isSale = plugin.type === 'paid' && priceInfo.isSale; + const desc = getDescription(plugin.translated_descriptions); + const initial = plugin.name.charAt(0).toUpperCase(); + + const slideshowImages = useMemo(() => { + const images = []; + if (plugin.preview_path) images.push(plugin.preview_path); + if (plugin.screenshots && plugin.screenshots.length > 0) { + images.push(...plugin.screenshots); + } + return images; + }, [plugin.preview_path, plugin.screenshots]); + + const startHover = () => { + setIsHovering(true); + if (slideshowImages.length <= 1) return; + + timerRef.current = setTimeout(() => { + cycleRef.current = setInterval(() => { + setCurrentIndex(prev => (prev + 1) % slideshowImages.length); + }, 3000); + }, 500); + }; + + const stopHover = () => { + setIsHovering(false); + setCurrentIndex(0); + if (timerRef.current) clearTimeout(timerRef.current); + if (cycleRef.current) clearInterval(cycleRef.current); + }; + + return ( + +
+
+ {slideshowImages.length > 0 ? ( +
+ {slideshowImages.map((src: string, i: number) => ( + {plugin.name} + ))} + + {isHovering && slideshowImages.length > 1 && ( +
+ {slideshowImages.map((_: any, i: number) => ( +
+ ))} +
+ )} +
+ ) : ( +
+ {initial} +
+ )} + +
+ {plugin.category} + + {plugin.type === 'free' ? ( + Free + ) : isSale ? ( + + {priceInfo.originalDisplay} + {priceInfo.display} + -{plugin.sale_discount_percent}% + + ) : ( + {priceInfo.display} + )} +
+ +
+

+ {plugin.name} + {plugin.is_early_access && Early Access} +

+

by {plugin.dev_name}

+

{desc}

+ +
+ + + + + {(plugin.downloads ?? 0).toLocaleString()} + + {plugin.rating != null && ( + + + + + {Number(plugin.rating).toFixed(1)} + + )} + {plugin.version && ( + + v{plugin.version} + + )} +
+
+ + {isSale && ( +
+ 🔥 Sale — {plugin.sale_discount_percent}% off +
+ )} + +
+
+ + ); +}; + +export default PluginCard; \ No newline at end of file diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f33b0a0..4c8c55a 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -5,7 +5,7 @@ import api from '../api'; import SEO from '../components/SEO'; const Home = () => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [plugins, setPlugins] = useState([]); const hasFetched = useRef(false); @@ -22,218 +22,12 @@ const Home = () => { }); }, []); - 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]; - }; - - // Compute effective display price respecting active sale - 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: '', - }; - }; - return ( <> -

{t('welcome')}

@@ -242,101 +36,9 @@ const Home = () => {
- {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; - console.log(imageUrl); - - return ( - -
- - {/* Preview image */} -
- {imageUrl ? ( - {plugin.name} { - // Fallback if image 404s - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - ) : ( -
- {initial} -
- )} -
- {plugin.category} - - {/* Price badge */} - {plugin.type === 'free' ? ( - Free - ) : isSale ? ( - - {priceInfo.originalDisplay} - {priceInfo.display} - -{plugin.sale_discount_percent}% - - ) : ( - {priceInfo.display} - )} -
- - {/* Info */} -
-

{plugin.name}

-

by {plugin.dev_name}

-

{desc}

- -
- - - - - {(plugin.downloads ?? 0).toLocaleString()} - - {plugin.rating != null && ( - - - - - {Number(plugin.rating).toFixed(1)} - - )} - {plugin.version && ( - - v{plugin.version} - - )} -
-
- - {/* Sale strip at bottom */} - {isSale && ( -
- 🔥 Sale — {plugin.sale_discount_percent}% off -
- )} - -
-
- - ); - })} + {plugins.map((plugin) => ( + + ))} {plugins.length === 0 && (
diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index 3720f98..38c1e67 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -109,9 +109,15 @@ const PluginDetail = () => { // Review Report State const [reviewReportOpen, setReviewReportOpen] = useState(null); - const [reviewReportSelected, setReviewReportSelected] = useState(''); + const [reviewReportSelected, setReviewReportSelected] = useState(''); const [reviewReportCustom, setReviewReportCustom] = useState(''); const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false); + + const [replyOpen, setReplyOpen] = useState(null); + const [replyText, setReplyText] = useState(''); + const [replySubmitting, setReplySubmitting] = useState(false); + + const isOwner = user && plugin && user.id === plugin.dev_id; const [reportedReviews, setReportedReviews] = useState>(new Set()); // ── Detect ?payment=success and strip from URL ─────────────────────────── @@ -289,6 +295,34 @@ const PluginDetail = () => { 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; @@ -390,6 +424,20 @@ const PluginDetail = () => { {JSON.stringify(productSchema)} + {/* ── Early Access Banner ── */} + {plugin.is_early_access && ( +
+
⏳
+
+
Early Access Plugin
+
+ This plugin is currently in Early Access. It is still under active development and may contain bugs or incomplete features. + Downloading it now helps support the developer and shape the future of the plugin! +
+
+
+ )} + {/* ── Payment success banner ── */} {showPaymentSuccess && (
@@ -514,13 +562,30 @@ const PluginDetail = () => { ))}

{r.comment}

+ + {/* Developer Reply */} + {r.developer_reply && ( +
+
+ Developer Response + {formatDate(r.replied_at)} +
+

{r.developer_reply}

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

✓ Report submitted

) : reviewReportOpen === r.id ? ( -
submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px' }}> + submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}>
{REVIEW_REPORT_REASONS.map(reason => ( @@ -563,12 +628,58 @@ const PluginDetail = () => {
+ ) : replyOpen === r.id ? ( +
submitReply(e, r.id)} style={{ width: '100%', maxWidth: '500px' }}> +