added more things :D

This commit is contained in:
Alexander Medvedev 2026-04-26 10:15:33 +02:00
parent e3b92108c1
commit 4de3327b20
9 changed files with 1001 additions and 421 deletions

View File

@ -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;

View File

@ -212,18 +212,31 @@ const Navbar = ({ user }: any) => {
{user && <NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>}
{user && user.role === 'admin' && <NavLink to="/admin" onClick={() => setIsMenuOpen(false)}>Admin</NavLink>}
</div>
<div className="nav-search">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
{!user && (
<div className="nav-search">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
)}
<div className="nav-actions">
{user ? (
<>
<div className="nav-search-right">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
</>
) : (

View File

@ -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<any>(null);
const cycleRef = useRef<any>(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 (
<Link to={`/plugin/${plugin.id}`} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
<div className="plugin-card-v2">
<div className="pcv2-preview">
{slideshowImages.length > 0 ? (
<div className="pcv2-slideshow">
{slideshowImages.map((src: string, i: number) => (
<img
key={src}
src={src}
className={`pcv2-slideshow-img ${i === currentIndex ? 'active' : ''}`}
alt={plugin.name}
/>
))}
{isHovering && slideshowImages.length > 1 && (
<div className="pcv2-slideshow-progress">
{slideshowImages.map((_: any, i: number) => (
<div key={i} className={`pcv2-progress-dot ${i === currentIndex ? 'active' : ''}`} />
))}
</div>
)}
</div>
) : (
<div className="pcv2-preview-fallback" style={{ background: `linear-gradient(135deg, ${accent}22 0%, #0d0f12 100%)` }}>
<span style={{ color: accent }}>{initial}</span>
</div>
)}
<div className="pcv2-preview-fade" />
<span className="pcv2-category-badge">{plugin.category}</span>
{plugin.type === 'free' ? (
<span className="pcv2-price-badge free">Free</span>
) : isSale ? (
<span className="pcv2-price-badge sale">
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
{priceInfo.display}
<span className="pcv2-sale-pct">-{plugin.sale_discount_percent}%</span>
</span>
) : (
<span className="pcv2-price-badge paid">{priceInfo.display}</span>
)}
</div>
<div className="pcv2-info">
<p className="pcv2-name">
{plugin.name}
{plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>}
</p>
<p className="pcv2-dev">by {plugin.dev_name}</p>
<p className="pcv2-desc">{desc}</p>
<div className="pcv2-footer">
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{(plugin.downloads ?? 0).toLocaleString()}
</span>
{plugin.rating != null && (
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" opacity="0.7"/>
</svg>
{Number(plugin.rating).toFixed(1)}
</span>
)}
{plugin.version && (
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
v{plugin.version}
</span>
)}
</div>
</div>
{isSale && (
<div className="pcv2-sale-strip">
🔥 Sale — {plugin.sale_discount_percent}% off
</div>
)}
<div
className="pcv2-accent-bar"
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
/>
</div>
</Link>
);
};
export default PluginCard;

View File

@ -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<any[]>([]);
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 (
<>
<SEO
title="Home"
description="Discover the best WASM-powered Minecraft plugins at Pumpkin Market. Performance, security, and variety in one place."
/>
<style>{`
.home-plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
padding: 2rem 0 4rem;
}
.plugin-card-link { text-decoration: none; display: block; }
.plugin-card-v2 {
position: relative;
border-radius: 10px;
overflow: hidden;
background: var(--mp-surface, #16191f);
border: 1px solid var(--mp-border, 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;
}
.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: var(--mp-bg, #0d0f12);
}
.pcv2-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.35s ease;
}
.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: var(--font-display, 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;
}
.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;
}
.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;
}
.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; }
.pcv2-name {
font-size: 0.95rem;
font-weight: 700;
color: var(--mp-text-1, #f0f0f0);
margin: 0 0 0.15rem;
font-family: var(--font-display, system-ui);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pcv2-dev { font-size: 0.72rem; color: var(--mp-text-3, #5a6070); margin: 0 0 0.55rem; }
.pcv2-desc {
font-size: 0.77rem;
color: var(--mp-text-2, #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 var(--mp-border, rgba(255,255,255,0.06));
}
.pcv2-stat {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.7rem; color: var(--mp-text-3, #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; }
/* Sale banner strip at bottom of card */
.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;
}
`}</style>
<section className="hero">
<h1>{t('welcome')}</h1>
@ -242,101 +36,9 @@ const Home = () => {
<div className="container" id="browse">
<div className="home-plugin-grid">
{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 (
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
<div className="plugin-card-v2">
{/* Preview image */}
<div className="pcv2-preview">
{imageUrl ? (
<img
className="pcv2-preview-img"
src={imageUrl}
alt={plugin.name}
onError={(e) => {
// Fallback if image 404s
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div
className="pcv2-preview-fallback"
style={{ background: `linear-gradient(135deg, ${accent}22 0%, #0d0f12 100%)` }}
>
<span style={{ color: accent }}>{initial}</span>
</div>
)}
<div className="pcv2-preview-fade" />
<span className="pcv2-category-badge">{plugin.category}</span>
{/* Price badge */}
{plugin.type === 'free' ? (
<span className="pcv2-price-badge free">Free</span>
) : isSale ? (
<span className="pcv2-price-badge sale">
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
{priceInfo.display}
<span className="pcv2-sale-pct">-{plugin.sale_discount_percent}%</span>
</span>
) : (
<span className="pcv2-price-badge paid">{priceInfo.display}</span>
)}
</div>
{/* Info */}
<div className="pcv2-info">
<p className="pcv2-name">{plugin.name}</p>
<p className="pcv2-dev">by {plugin.dev_name}</p>
<p className="pcv2-desc">{desc}</p>
<div className="pcv2-footer">
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{(plugin.downloads ?? 0).toLocaleString()}
</span>
{plugin.rating != null && (
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" opacity="0.7"/>
</svg>
{Number(plugin.rating).toFixed(1)}
</span>
)}
{plugin.version && (
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
v{plugin.version}
</span>
)}
</div>
</div>
{/* Sale strip at bottom */}
{isSale && (
<div className="pcv2-sale-strip">
🔥 Sale — {plugin.sale_discount_percent}% off
</div>
)}
<div
className="pcv2-accent-bar"
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
/>
</div>
</Link>
);
})}
{plugins.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} />
))}
{plugins.length === 0 && (
<div className="empty-state">

View File

@ -109,9 +109,15 @@ const PluginDetail = () => {
// Review Report State
const [reviewReportOpen, setReviewReportOpen] = useState<number | null>(null);
const [reviewReportSelected, setReviewReportSelected] = useState<string>('');
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 ───────────────────────────
@ -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)}
</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">
@ -514,13 +562,30 @@ const PluginDetail = () => {
))}
</div>
<p className="review-item-body">{r.comment}</p>
{/* 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'
}}>
<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>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text)', lineHeight: 1.6 }}>{r.developer_reply}</p>
</div>
)}
{/* Review Report Button/Form */}
<div style={{ marginTop: '0.75rem' }}>
{/* 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' }}>
<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)', originX: 'left' }}>
{REVIEW_REPORT_REASONS.map(reason => (
@ -563,12 +628,58 @@ const PluginDetail = () => {
</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={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}>
🚩 Report review
</button>
)
<>
{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>
@ -579,10 +690,25 @@ const PluginDetail = () => {
hasReviewed ? (
<div className="review-already-posted">
<span className="review-already-icon">✓</span>
<div>
<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">

View File

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="container">
@ -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}.` : ''}`}
/>
<h1 className="section-title">
{searchQuery
? <>Search Results <span>for "{searchQuery}"</span></>
: 'All Plugins'
}
</h1>
<div className="search-page-layout">
<aside className="search-sidebar">
<div className="filter-section">
<h3 className="filter-title">Filters</h3>
<button className="btn-text-only" onClick={() => {
setCategory('');
setType('');
setMinPrice('');
setMaxPrice('');
}}>Clear All</button>
</div>
<div className="search-controls">
<label htmlFor="sort-by">Sort by:</label>
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="">Relevance</option>
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="price-asc">Price (Low to High)</option>
<option value="price-desc">Price (High to Low)</option>
<option value="newest">Newest</option>
</select>
</div>
<div className="filter-group">
<label>Category</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="">All Categories</option>
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
{error && <p className="error-message">{error}</p>}
<div className="filter-group">
<label>License Type</label>
<select value={type} onChange={e => setType(e.target.value)}>
<option value="">All Types</option>
<option value="free">Free</option>
<option value="paid">Paid</option>
<option value="adwall">Adwall</option>
</select>
</div>
{loading ? (
<p>Loading results...</p>
) : (
<div className="plugin-grid">
{visiblePlugins.length > 0 ? (
visiblePlugins.map((plugin) => (
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
<div className="plugin-card">
<div className="plugin-card-header">
<div
className="plugin-icon"
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
>
{!plugin.preview_path && plugin.name.charAt(0)}
</div>
<div>
<h3>{plugin.name}</h3>
<p className="dev-name">by {plugin.dev_name}</p>
</div>
</div>
<p>
{(() => {
const desc = getDescription(plugin);
return desc.length > 100 ? desc.substring(0, 100) + '…' : desc || 'No description provided.';
})()}
</p>
<div className="card-footer">
<span className="plugin-category">{plugin.category}</span>
<span className={`badge badge-${plugin.type}`}>
{plugin.type === 'paid' ? `$${plugin.price}` : plugin.type}
</span>
</div>
</div>
</Link>
))
<div className="filter-group">
<label>Price Range ($)</label>
<div className="price-range-inputs">
<input
type="number"
placeholder="Min"
value={minPrice}
onChange={e => setMinPrice(e.target.value)}
/>
<span>-</span>
<input
type="number"
placeholder="Max"
value={maxPrice}
onChange={e => setMaxPrice(e.target.value)}
/>
</div>
</div>
</aside>
<main className="search-main">
<div className="search-header">
<h1 className="search-title">
{searchQuery
? <>Results <span>for "{searchQuery}"</span></>
: 'All Plugins'
}
<small className="results-count">({plugins.length} found)</small>
</h1>
<div className="search-controls">
<label htmlFor="sort-by">Sort by:</label>
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="">Newest</option>
<option value="downloads">Most Popular</option>
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="price-asc">Price (Low to High)</option>
<option value="price-desc">Price (High to Low)</option>
</select>
</div>
</div>
{error && <p className="error-message">{error}</p>}
{loading ? (
<div className="loading-state">
<div className="spinner"></div>
<p>Searching market...</p>
</div>
) : (
<div className="empty-state">
<p>
{searchQuery
? `No plugins found for "${searchQuery}". Try a different search term!`
: 'No plugins available yet.'
}
</p>
<div className="plugin-grid search-grid">
{plugins.length > 0 ? (
plugins.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} />
))
) : (
<div className="empty-state">
<p>
{searchQuery
? `No plugins found for "${searchQuery}" with these filters.`
: 'No plugins available with these filters.'
}
</p>
<button className="btn btn-secondary" onClick={() => {
setCategory('');
setType('');
setMinPrice('');
setMaxPrice('');
}}>Clear Filters</button>
</div>
)}
</div>
)}
</div>
)}
</main>
</div>
</div>
);
};

View File

@ -223,6 +223,7 @@ const AddPlugin = () => {
const [licenseType, setLicenseType] = useState<LicenseType>('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 = () => {
</div>
{/* Keywords */}
<div className="mp-form-group" style={{ marginBottom: 0 }}>
<div className="mp-form-group" style={{ marginBottom: '1.5rem' }}>
<label className="mp-label">
Keywords{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
@ -509,6 +511,22 @@ const AddPlugin = () => {
/>
<FieldError msg={fieldErrors.keywords} />
</div>
{/* Early Access */}
<div className="mp-form-group">
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={isEarlyAccess}
onChange={e => setIsEarlyAccess(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<div>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--mp-text-1)' }}>Early Access</div>
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', fontWeight: 400 }}>Flag this plugin as incomplete or in active development.</div>
</div>
</label>
</div>
</div>
{/* Authorship */}

View File

@ -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 = [

View File

@ -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" />
</div>
<div className="mp-form-group">
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none', marginTop: '0.5rem' }}>
<input
type="checkbox"
checked={isEarlyAccess}
onChange={e => setIsEarlyAccess(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<div>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--mp-text)' }}>Early Access</div>
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', fontWeight: 400 }}>Flag this plugin as incomplete or in active development.</div>
</div>
</label>
</div>
</div>
{/* ── Descriptions ── */}