some updates

This commit is contained in:
Alexander Medvedev 2026-07-24 19:59:09 +02:00
parent a0bcd266a3
commit 23248fa42c
9 changed files with 1026 additions and 301 deletions

776
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,18 +17,18 @@
"country-list": "^2.4.1",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"i18next": "^26.3.4",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.23.0",
"lucide-react": "^1.26.0",
"or": "^0.2.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-is": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-is": "^19.2.8",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1",
"recharts": "^3.9.2",
"recharts": "^3.10.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@ -37,16 +37,16 @@
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.3"
"typescript": "^7.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^8.1.5"
},
"overrides": {
"vite": "^8.1.3"
"vite": "^8.1.5"
}
}

View File

@ -516,40 +516,287 @@ h3 {
}
}
/* --- Hero Section --- */
.hero {
text-align: center;
padding: 8rem 2rem;
background: radial-gradient(circle at 50% 30%, #1a1200 0%, var(--bg) 70%);
/* --- Featured Plugin Showcase Hero (Steam / App Store Style) --- */
.featured-showcase-container {
max-width: 1600px;
margin: 2rem auto 3rem auto;
padding: 0 2rem;
}
.hero h1 {
font-size: clamp(3rem, 8vw, 5.5rem);
line-height: 1;
margin-bottom: 1rem;
.featured-showcase-main {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.hero p {
font-size: 1.25rem;
max-width: 600px;
margin: 0 auto 2rem auto;
color: var(--text-muted);
.featured-showcase-main:hover {
border-color: rgba(255, 117, 24, 0.4);
box-shadow: 0 24px 50px rgba(255, 117, 24, 0.15);
}
.hero-actions {
.featured-showcase-link {
display: grid;
grid-template-columns: 1fr 340px;
color: inherit;
text-decoration: none;
min-height: 440px;
}
@media (max-width: 1024px) {
.featured-showcase-link {
grid-template-columns: 1fr;
}
}
.featured-main-visual {
position: relative;
background: #000;
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 320px;
}
.hero-stats {
.featured-main-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.5s ease;
}
.featured-showcase-link:hover .featured-main-img {
transform: scale(1.02);
}
.featured-visual-fallback {
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 117, 24, 0.15) 0%, #0d0f12 100%);
display: flex;
gap: 3rem;
align-items: center;
justify-content: center;
margin-top: 5rem;
font-family: var(--font-mono);
color: var(--text-muted);
font-size: 5rem;
font-weight: 900;
color: var(--primary);
}
.featured-badge-overlay {
position: absolute;
top: 1rem;
left: 1rem;
display: flex;
gap: 0.5rem;
z-index: 2;
}
.featured-tag {
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
padding: 6px 12px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.featured-price-tag {
font-size: 0.75rem;
font-weight: 800;
padding: 6px 12px;
border-radius: 6px;
text-transform: uppercase;
}
.featured-price-tag.free {
background: var(--success);
color: #000;
}
.featured-price-tag.sale {
background: #ff4757;
color: #fff;
}
.featured-price-tag.paid {
background: var(--primary);
color: #fff;
}
.featured-sidebar-info {
padding: 2rem;
display: flex;
flex-direction: column;
background: rgba(20, 20, 20, 0.95);
border-left: 1px solid var(--border);
}
@media (max-width: 1024px) {
.featured-sidebar-info {
border-left: none;
border-top: 1px solid var(--border);
}
}
.featured-title {
font-size: 1.75rem;
font-weight: 900;
margin: 0 0 0.25rem 0;
color: var(--text);
line-height: 1.2;
}
.featured-dev {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 1.25rem;
}
.featured-screenshots-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.featured-thumb {
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
border: 2px solid transparent;
background: #000;
cursor: pointer;
transition: all 0.2s ease;
}
.featured-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.featured-thumb:hover img,
.featured-thumb.active img {
opacity: 1;
}
.featured-thumb.active {
border-color: var(--primary);
}
.featured-desc {
font-size: 0.875rem;
line-height: 1.5;
color: #ccc;
margin-bottom: auto;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.featured-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1.5rem;
margin-bottom: 1rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.featured-stat {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.featured-version {
font-family: var(--font-mono);
background: rgba(255, 255, 255, 0.06);
padding: 3px 8px;
border-radius: 4px;
}
.featured-action-btn {
background: var(--primary);
color: #fff;
font-weight: 800;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.5px;
padding: 12px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.2s ease, transform 0.2s ease;
}
.featured-showcase-link:hover .featured-action-btn {
background: #ff8533;
}
.featured-nav-dots {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
justify-content: center;
}
.featured-dot-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
max-width: 180px;
transition: color 0.2s ease;
}
.featured-dot-btn .dot-title {
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.featured-dot-btn .dot-bar {
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
transition: background 0.3s ease;
}
.featured-dot-btn:hover .dot-bar {
background: rgba(255, 255, 255, 0.4);
}
.featured-dot-btn.active {
color: var(--text);
}
.featured-dot-btn.active .dot-bar {
background: var(--primary);
}
/* --- Plugin Grid --- */

View File

@ -109,8 +109,6 @@ const PluginCard = ({ plugin }: { plugin: any }) => {
</div>
)}
<span className="pcv2-category-badge">{plugin.category}</span>
{plugin.type === 'free' ? (
<span className="pcv2-price-badge free">Free</span>
) : isSale ? (
@ -157,7 +155,7 @@ const PluginCard = ({ plugin }: { plugin: any }) => {
{isSale && (
<div className="pcv2-sale-strip">
🔥 Sale — {plugin.sale_discount_percent}% off
Sale — {plugin.sale_discount_percent}% off
</div>
)}

View File

@ -22,7 +22,6 @@ i18n
.init({
resources,
fallbackLng: 'en',
showSupportNotice: false,
interpolation: {
escapeValue: false, // React already escapes values
},

View File

@ -181,7 +181,7 @@ const AuthorProfilePage = () => {
</div>
</div>
{isSale && (
<div className="pcv2-sale-strip">🔥 Sale — {plugin.sale_discount_percent}% off</div>
<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>

View File

@ -1,81 +1,107 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import api from '../api';
import SEO from '../components/SEO';
import PluginCard from '../components/PluginCard';
// Global cache to persist data across component unmounts (navigation)
// This allows the page to render immediately when going "back",
// which is required for the browser to restore scroll position.
let homeCache: {
popular: any[];
newest: any[];
all: any[];
} | null = null;
const Home = () => {
const { t } = useTranslation();
const { i18n } = useTranslation();
const [popular, setPopular] = useState<any[]>(homeCache?.popular || []);
const [newest, setNewest] = useState<any[]>(homeCache?.newest || []);
// Load sections lazily when the user scrolls them into view
const [allPlugins, setAllPlugins] = useState<any[]>(homeCache?.all || []);
const [loading, setLoading] = useState(false);
const [heroIndex, setHeroIndex] = useState(0);
const [heroSubImageIndex, setHeroSubImageIndex] = useState(0);
const popularRef = useRef<HTMLElement | null>(null);
const newestRef = useRef<HTMLElement | null>(null);
const fetched = useRef({ popular: !!homeCache?.popular, newest: !!homeCache?.newest, all: !!homeCache?.all });
const fetched = useRef({ popular: !!homeCache?.popular, newest: !!homeCache?.newest });
const saveSection = (key: 'popular' | 'newest', data: any[]) => {
const saveSection = (key: 'popular' | 'newest' | 'all', data: any[]) => {
if (key === 'popular') setPopular(data);
if (key === 'newest') setNewest(data);
if (key === 'all') setAllPlugins(data);
homeCache = {
popular: key === 'popular' ? data : homeCache?.popular || [],
newest: key === 'newest' ? data : homeCache?.newest || []
newest: key === 'newest' ? data : homeCache?.newest || [],
all: key === 'all' ? data : homeCache?.all || []
};
fetched.current[key] = true;
};
useEffect(() => {
if (fetched.current.popular && fetched.current.newest) return;
if (fetched.current.popular && fetched.current.newest && fetched.current.all) return;
const observers: IntersectionObserver[] = [];
const createObserver = (el: HTMLElement | null, key: 'popular' | 'newest', params: any) => {
if (!el || fetched.current[key]) return;
const obs = new IntersectionObserver(async (entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
fetched.current[key] = true; // prevent duplicate triggers
try {
setLoading(true);
const res = await api.get('/plugins', { params });
const list = Array.isArray(res.data) ? res.data : [];
saveSection(key, list);
} catch (err) {
console.error('Fetch error for', key, err);
} finally {
setLoading(false);
observer.disconnect();
}
}
}
}, { rootMargin: '200px' });
obs.observe(el);
observers.push(obs);
const loadSections = async () => {
setLoading(true);
try {
const [popRes, newRes, allRes] = await Promise.all([
api.get('/plugins', { params: { sort: 'downloads' } }),
api.get('/plugins', { params: { sort: 'newest' } }),
api.get('/plugins')
]);
saveSection('popular', Array.isArray(popRes.data) ? popRes.data : []);
saveSection('newest', Array.isArray(newRes.data) ? newRes.data : []);
saveSection('all', Array.isArray(allRes.data) ? allRes.data : []);
} catch (err) {
console.error('Fetch error for home sections:', err);
} finally {
setLoading(false);
}
};
// Ensure refs are attached before creating observers
requestAnimationFrame(() => {
createObserver(popularRef.current, 'popular', { sort: 'downloads', limit: 8 });
createObserver(newestRef.current, 'newest', { sort: 'newest', limit: 12 });
});
return () => observers.forEach(o => o.disconnect());
loadSections();
}, []);
if (loading) {
// Featured plugins for top Steam-like carousel
const featuredPlugins = useMemo(() => popular.slice(0, 5), [popular]);
// Auto rotate hero slide every 6s
useEffect(() => {
if (featuredPlugins.length <= 1) return;
const timer = setInterval(() => {
setHeroIndex((prev) => (prev + 1) % featuredPlugins.length);
setHeroSubImageIndex(0);
}, 6000);
return () => clearInterval(timer);
}, [featuredPlugins.length]);
const activeFeatured = featuredPlugins[heroIndex];
const featuredImages = useMemo(() => {
if (!activeFeatured) return [];
const imgs = [];
if (activeFeatured.preview_path) imgs.push(activeFeatured.preview_path);
if (activeFeatured.screenshots && activeFeatured.screenshots.length > 0) {
imgs.push(...activeFeatured.screenshots);
}
return imgs;
}, [activeFeatured]);
const activeImage = featuredImages[heroSubImageIndex] || featuredImages[0];
const getDesc = (translatedStr?: string) => {
if (!translatedStr) return 'Discover this high performance Minecraft plugin.';
try {
const data = JSON.parse(translatedStr);
const currentLang = i18n.language.split('-')[0];
return data[currentLang] || data.en || Object.values(data)[0] || 'Discover this high performance Minecraft plugin.';
} catch {
return 'Discover this high performance Minecraft plugin.';
}
};
if (loading && popular.length === 0) {
return (
<div className="loading-state">
<div className="spinner"></div>
@ -91,10 +117,98 @@ const Home = () => {
description="Discover the best WASM-powered Minecraft plugins at Pumpkin Market. Performance, security, and variety in one place."
/>
<section className="hero">
<h1>{t('welcome')}</h1>
<p>Explore powerful plugins for your PumpkinMC experience.</p>
</section>
{activeFeatured && (
<section className="featured-showcase-container">
<div className="featured-showcase-main">
<Link to={`/plugin/${activeFeatured.id}`} className="featured-showcase-link">
{/* Main visual display */}
<div className="featured-main-visual">
{activeImage ? (
<img src={activeImage} alt={activeFeatured.name} className="featured-main-img" />
) : (
<div className="featured-visual-fallback">
<span>{activeFeatured.name.charAt(0).toUpperCase()}</span>
</div>
)}
<div className="featured-badge-overlay">
<span className="featured-tag">Trending & Popular</span>
{activeFeatured.type === 'free' ? (
<span className="featured-price-tag free">Free</span>
) : activeFeatured.sale_active && activeFeatured.sale_discount_percent > 0 ? (
<span className="featured-price-tag sale">
-{activeFeatured.sale_discount_percent}% OFF
</span>
) : (
<span className="featured-price-tag paid">
€{((activeFeatured.price_cents || 0) / 100).toFixed(2)}
</span>
)}
</div>
</div>
{/* Sidebar Info */}
<div className="featured-sidebar-info">
<h2 className="featured-title">{activeFeatured.name}</h2>
<p className="featured-dev">by <strong>{activeFeatured.dev_name}</strong></p>
<div className="featured-screenshots-grid">
{featuredImages.slice(0, 4).map((src: string, idx: number) => (
<div
key={src + idx}
className={`featured-thumb ${idx === heroSubImageIndex ? 'active' : ''}`}
onMouseEnter={(e) => {
e.preventDefault();
setHeroSubImageIndex(idx);
}}
>
<img src={src} alt="thumbnail" />
</div>
))}
</div>
<p className="featured-desc">{getDesc(activeFeatured.translated_descriptions)}</p>
<div className="featured-meta">
<div className="featured-stat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" 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>
<span>{(activeFeatured.downloads || 0).toLocaleString()} Downloads</span>
</div>
{activeFeatured.version && (
<span className="featured-version">v{activeFeatured.version}</span>
)}
</div>
<div className="featured-action-btn">
<span>View Plugin</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
</div>
</div>
</Link>
</div>
{/* Navigation Dots / Controls */}
<div className="featured-nav-dots">
{featuredPlugins.map((item, idx) => (
<button
key={item.id}
className={`featured-dot-btn ${idx === heroIndex ? 'active' : ''}`}
onClick={() => {
setHeroIndex(idx);
setHeroSubImageIndex(0);
}}
>
<span className="dot-title">{item.name}</span>
<div className="dot-bar" />
</button>
))}
</div>
</section>
)}
<div className="container" id="browse">
@ -124,7 +238,20 @@ const Home = () => {
</div>
</section>
{(fetched.current?.popular && fetched.current?.newest && popular.length === 0 && newest.length === 0) && (
<section className="home-section">
<h2 className="section-title"><span>All</span> Plugins ({allPlugins.length})</h2>
<div className="home-plugin-grid">
{allPlugins.length > 0 ? (
allPlugins.map((plugin) => (
<PluginCard key={`all-${plugin.id}`} plugin={plugin} />
))
) : (
<div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</div>
)}
</div>
</section>
{(fetched.current?.popular && fetched.current?.newest && popular.length === 0 && newest.length === 0 && allPlugins.length === 0) && (
<div className="empty-state">
<p>No plugins found. Check back later!</p>
</div>

View File

@ -332,9 +332,9 @@ const PluginDetail = () => {
// ── Download / purchase handler ──────────────────────────────────────────
const handleDownload = async () => {
if (plugin.type === 'paid') {
if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } });
if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } });
if (plugin.type === 'paid') {
// Already owns it — download directly, no checkout
if (ownsPlugin) {
try {

View File

@ -247,7 +247,7 @@ const Pricing = ({ plugin, onSaved }: Props) => {
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem 1rem', background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.25)', borderRadius: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.82rem', fontWeight: 700, color: '#ff6b6b' }}>
🔥 Sale is live — {discountPercent}% off
Sale is live — {discountPercent}% off
</div>
<button
type="button"