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) => (
+
+ ))}
+
+ {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 ? (
-
{
- // 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 ? (
-
+ ) : replyOpen === r.id ? (
+
) : (
- user && user.username !== r.username && (
-
setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}>
- 🚩 Report review
-
- )
+ <>
+ {user && user.username === r.username ? (
+
+ 🗑 Delete my review
+
+ ) : user && (
+
setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}>
+ 🚩 Report review
+
+ )}
+
+ {isOwner && !r.developer_reply && !replyOpen && (
+
setReplyOpen(r.id)}
+ style={{ fontSize: '0.7rem', color: 'var(--primary)' }}
+ >
+ 💬 Reply as developer
+
+ )}
+ >
)}
@@ -579,10 +690,25 @@ const PluginDetail = () => {
hasReviewed ? (
✓
-
+
Review posted
You've already reviewed this plugin. Thanks for the feedback!
+
+ Delete Review
+
) : (
diff --git a/src/pages/SearchResultsPage.tsx b/src/pages/SearchResultsPage.tsx
index faf9b20..d0f9f32 100644
--- a/src/pages/SearchResultsPage.tsx
+++ b/src/pages/SearchResultsPage.tsx
@@ -1,25 +1,43 @@
import { useState, useEffect } from 'react';
-import { useSearchParams, Link } from 'react-router-dom';
+import { useSearchParams } from 'react-router-dom';
import api from '../api';
import SEO from '../components/SEO';
+import PluginCard from '../components/PluginCard';
+
+const PLUGIN_CATEGORIES = ['Admin Tools', 'Economy', 'Fun', 'World Management', 'Utilities', 'Chat', 'Other'];
const SearchResultsPage = () => {
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
const searchQuery = searchParams.get('q') || '';
+ const initialCategory = searchParams.get('category') || '';
+ const initialType = searchParams.get('type') || '';
+ const initialMinPrice = searchParams.get('min_price') || '';
+ const initialMaxPrice = searchParams.get('max_price') || '';
+
const [plugins, setPlugins] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [sort, setSort] = useState('');
+
+ const [sort, setSort] = useState(searchParams.get('sort') || '');
+ const [category, setCategory] = useState(initialCategory);
+ const [type, setType] = useState(initialType);
+ const [minPrice, setMinPrice] = useState(initialMinPrice);
+ const [maxPrice, setMaxPrice] = useState(initialMaxPrice);
- // Fetch ALL plugins whenever sort changes — no search filter sent to API
useEffect(() => {
const fetchPlugins = async () => {
setLoading(true);
setError(null);
try {
- const response = await api.get('/plugins', {
- params: { sort: sort || undefined },
- });
+ const params: any = {
+ q: searchQuery || undefined,
+ sort: sort || undefined,
+ category: category || undefined,
+ type: type || undefined,
+ min_price: minPrice ? parseInt(minPrice) * 100 : undefined,
+ max_price: maxPrice ? parseInt(maxPrice) * 100 : undefined,
+ };
+ const response = await api.get('/plugins', { params });
setPlugins(Array.isArray(response.data) ? response.data : []);
} catch (err: any) {
console.error('Error fetching plugins:', err);
@@ -31,30 +49,18 @@ const SearchResultsPage = () => {
};
fetchPlugins();
- }, [sort]);
- // Client-side filter by search query
- const getDescription = (plugin: any): string => {
- try {
- const descriptions = JSON.parse(plugin.translated_descriptions || '{}');
- return descriptions['en-US'] || descriptions.en || Object.values(descriptions)[0] as string || '';
- } catch {
- return '';
- }
- };
+ // Update URL params
+ const newParams: any = {};
+ if (searchQuery) newParams.q = searchQuery;
+ if (sort) newParams.sort = sort;
+ if (category) newParams.category = category;
+ if (type) newParams.type = type;
+ if (minPrice) newParams.min_price = minPrice;
+ if (maxPrice) newParams.max_price = maxPrice;
+ setSearchParams(newParams, { replace: true });
- const visiblePlugins = searchQuery.trim()
- ? plugins.filter(p => {
- const q = searchQuery.toLowerCase();
- return (
- p.name?.toLowerCase().includes(q) ||
- p.dev_name?.toLowerCase().includes(q) ||
- p.category?.toLowerCase().includes(q) ||
- p.keywords?.toLowerCase().includes(q) ||
- getDescription(p).toLowerCase().includes(q)
- );
- })
- : plugins;
+ }, [searchQuery, sort, category, type, minPrice, maxPrice, setSearchParams]);
return (
@@ -62,74 +68,113 @@ const SearchResultsPage = () => {
title={searchQuery ? `Search results for "${searchQuery}"` : "All Plugins"}
description={`Browse our collection of Minecraft plugins. ${searchQuery ? `Showing results for ${searchQuery}.` : ''}`}
/>
-
- {searchQuery
- ? <>Search Results for "{searchQuery}" >
- : 'All Plugins'
- }
-
+
+
+
+
+
Filters
+ {
+ setCategory('');
+ setType('');
+ setMinPrice('');
+ setMaxPrice('');
+ }}>Clear All
+
-
- Sort by:
- setSort(e.target.value)}>
- Relevance
- Name (A-Z)
- Name (Z-A)
- Price (Low to High)
- Price (High to Low)
- Newest
-
-
+
+ Category
+ setCategory(e.target.value)}>
+ All Categories
+ {PLUGIN_CATEGORIES.map(c => {c} )}
+
+
- {error && {error}
}
+
+ License Type
+ setType(e.target.value)}>
+ All Types
+ Free
+ Paid
+ Adwall
+
+
- {loading ? (
- Loading results...
- ) : (
-
- {visiblePlugins.length > 0 ? (
- visiblePlugins.map((plugin) => (
-
-
-
-
- {!plugin.preview_path && plugin.name.charAt(0)}
-
-
-
{plugin.name}
-
by {plugin.dev_name}
-
-
-
- {(() => {
- const desc = getDescription(plugin);
- return desc.length > 100 ? desc.substring(0, 100) + '…' : desc || 'No description provided.';
- })()}
-
-
- {plugin.category}
-
- {plugin.type === 'paid' ? `$${plugin.price}` : plugin.type}
-
-
-
-
- ))
+
+
+
+
+
+
+ {searchQuery
+ ? <>Results for "{searchQuery}" >
+ : 'All Plugins'
+ }
+ ({plugins.length} found)
+
+
+
+ Sort by:
+ setSort(e.target.value)}>
+ Newest
+ Most Popular
+ Name (A-Z)
+ Name (Z-A)
+ Price (Low to High)
+ Price (High to Low)
+
+
+
+
+ {error && {error}
}
+
+ {loading ? (
+
+
+
Searching market...
+
) : (
-
-
- {searchQuery
- ? `No plugins found for "${searchQuery}". Try a different search term!`
- : 'No plugins available yet.'
- }
-
+
+ {plugins.length > 0 ? (
+ plugins.map((plugin) => (
+
+ ))
+ ) : (
+
+
+ {searchQuery
+ ? `No plugins found for "${searchQuery}" with these filters.`
+ : 'No plugins available with these filters.'
+ }
+
+
{
+ setCategory('');
+ setType('');
+ setMinPrice('');
+ setMaxPrice('');
+ }}>Clear Filters
+
+ )}
)}
-
- )}
+
+
);
};
diff --git a/src/pages/dashboard/plugin/AddPlugin.tsx b/src/pages/dashboard/plugin/AddPlugin.tsx
index 6d3c7ad..e683bc0 100644
--- a/src/pages/dashboard/plugin/AddPlugin.tsx
+++ b/src/pages/dashboard/plugin/AddPlugin.tsx
@@ -223,6 +223,7 @@ const AddPlugin = () => {
const [licenseType, setLicenseType] = useState
('free');
const [price, setPrice] = useState(4.99);
+ const [isEarlyAccess, setIsEarlyAccess] = useState(false);
const stripeConnected = user.stripe_ready;
// ── Step 2: Binary ──
@@ -356,6 +357,7 @@ const AddPlugin = () => {
fd.append('translated_descriptions', JSON.stringify(descriptions));
fd.append('type', licenseType);
fd.append('price', licenseType === 'paid' ? String(Math.round(price * 100)) : '0');
+ fd.append('is_early_access', String(isEarlyAccess));
if (previewImage) fd.append('preview_image', previewImage);
if (wasmFile) fd.append('wasm', wasmFile);
screenshots.forEach(f => fd.append('screenshots', f));
@@ -374,7 +376,7 @@ const AddPlugin = () => {
setDescriptions({ [DEFAULT_LOCALE]: '' }); setActiveLocale(DEFAULT_LOCALE);
setPreviewImage(null); setPreviewImageUrl(null);
setScreenshots([]); setScreenshotPreviews([]);
- setLicenseType('free'); setPrice(4.99);
+ setLicenseType('free'); setPrice(4.99); setIsEarlyAccess(false);
setWasmFile(null);
};
@@ -493,7 +495,7 @@ const AddPlugin = () => {
{/* Keywords */}
-
+
Keywords{' '}
(comma-separated)
@@ -509,6 +511,22 @@ const AddPlugin = () => {
/>
+
+ {/* Early Access */}
+
+
+ setIsEarlyAccess(e.target.checked)}
+ style={{ width: '18px', height: '18px', cursor: 'pointer' }}
+ />
+
+
Early Access
+
Flag this plugin as incomplete or in active development.
+
+
+
{/* Authorship */}
diff --git a/src/pages/dashboard/plugin/ManagePlugin.tsx b/src/pages/dashboard/plugin/ManagePlugin.tsx
index 1a2abec..712348b 100644
--- a/src/pages/dashboard/plugin/ManagePlugin.tsx
+++ b/src/pages/dashboard/plugin/ManagePlugin.tsx
@@ -26,6 +26,7 @@ export type PluginData = {
status?: 'published' | 'draft' | 'review';
sale_active?: boolean;
sale_discount_percent?: number;
+ is_early_access?: boolean;
};
const NAV_ITEMS = [
diff --git a/src/pages/dashboard/plugin/StoreListing.tsx b/src/pages/dashboard/plugin/StoreListing.tsx
index 6763d11..f20539e 100644
--- a/src/pages/dashboard/plugin/StoreListing.tsx
+++ b/src/pages/dashboard/plugin/StoreListing.tsx
@@ -148,6 +148,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const [category, setCategory] = useState(plugin.category);
const [sourceLink, setSourceLink] = useState(plugin.source_link || '');
const [keywords, setKeywords] = useState(plugin.keywords || '');
+ const [isEarlyAccess, setIsEarlyAccess] = useState(plugin.is_early_access || false);
const [screenshots, setScreenshots] = useState(plugin.screenshots || []);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -181,6 +182,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
fd.append('category', category);
fd.append('source_link', sourceLink);
fd.append('keywords', keywords);
+ fd.append('is_early_access', String(isEarlyAccess));
try {
await api.put(`/plugins/${plugin.id}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -259,6 +261,21 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency" />
+
+
+
+ setIsEarlyAccess(e.target.checked)}
+ style={{ width: '18px', height: '18px', cursor: 'pointer' }}
+ />
+
+
Early Access
+
Flag this plugin as incomplete or in active development.
+
+
+
{/* ── Descriptions ── */}