added more things :D
This commit is contained in:
parent
e3b92108c1
commit
4de3327b20
497
src/App.css
497
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;
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
175
src/components/PluginCard.tsx
Normal file
175
src/components/PluginCard.tsx
Normal 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;
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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 ── */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user