some updates
This commit is contained in:
parent
a0bcd266a3
commit
23248fa42c
776
package-lock.json
generated
776
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -17,18 +17,18 @@
|
|||||||
"country-list": "^2.4.1",
|
"country-list": "^2.4.1",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.26.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.11",
|
||||||
"react-is": "^19.2.7",
|
"react-is": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.10.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -37,16 +37,16 @@
|
|||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.7.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.63.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.1.3"
|
"vite": "^8.1.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "^8.1.3"
|
"vite": "^8.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
289
src/App.css
289
src/App.css
@ -516,40 +516,287 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Hero Section --- */
|
/* --- Featured Plugin Showcase Hero (Steam / App Store Style) --- */
|
||||||
.hero {
|
.featured-showcase-container {
|
||||||
text-align: center;
|
max-width: 1600px;
|
||||||
padding: 8rem 2rem;
|
margin: 2rem auto 3rem auto;
|
||||||
background: radial-gradient(circle at 50% 30%, #1a1200 0%, var(--bg) 70%);
|
padding: 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.featured-showcase-main {
|
||||||
font-size: clamp(3rem, 8vw, 5.5rem);
|
background: var(--surface);
|
||||||
line-height: 1;
|
border: 1px solid var(--border);
|
||||||
margin-bottom: 1rem;
|
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 {
|
.featured-showcase-main:hover {
|
||||||
font-size: 1.25rem;
|
border-color: rgba(255, 117, 24, 0.4);
|
||||||
max-width: 600px;
|
box-shadow: 0 24px 50px rgba(255, 117, 24, 0.15);
|
||||||
margin: 0 auto 2rem auto;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
gap: 1rem;
|
align-items: center;
|
||||||
justify-content: 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;
|
display: flex;
|
||||||
gap: 3rem;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 5rem;
|
font-size: 5rem;
|
||||||
font-family: var(--font-mono);
|
font-weight: 900;
|
||||||
color: var(--text-muted);
|
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;
|
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 --- */
|
/* --- Plugin Grid --- */
|
||||||
|
|||||||
@ -109,8 +109,6 @@ const PluginCard = ({ plugin }: { plugin: any }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="pcv2-category-badge">{plugin.category}</span>
|
|
||||||
|
|
||||||
{plugin.type === 'free' ? (
|
{plugin.type === 'free' ? (
|
||||||
<span className="pcv2-price-badge free">Free</span>
|
<span className="pcv2-price-badge free">Free</span>
|
||||||
) : isSale ? (
|
) : isSale ? (
|
||||||
@ -157,7 +155,7 @@ const PluginCard = ({ plugin }: { plugin: any }) => {
|
|||||||
|
|
||||||
{isSale && (
|
{isSale && (
|
||||||
<div className="pcv2-sale-strip">
|
<div className="pcv2-sale-strip">
|
||||||
🔥 Sale — {plugin.sale_discount_percent}% off
|
Sale — {plugin.sale_discount_percent}% off
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ i18n
|
|||||||
.init({
|
.init({
|
||||||
resources,
|
resources,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
showSupportNotice: false,
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // React already escapes values
|
escapeValue: false, // React already escapes values
|
||||||
},
|
},
|
||||||
|
|||||||
@ -181,7 +181,7 @@ const AuthorProfilePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSale && (
|
{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 className="pcv2-accent-bar" style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,81 +1,107 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import SEO from '../components/SEO';
|
import SEO from '../components/SEO';
|
||||||
import PluginCard from '../components/PluginCard';
|
import PluginCard from '../components/PluginCard';
|
||||||
|
|
||||||
// Global cache to persist data across component unmounts (navigation)
|
// 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: {
|
let homeCache: {
|
||||||
popular: any[];
|
popular: any[];
|
||||||
newest: any[];
|
newest: any[];
|
||||||
|
all: any[];
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { t } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [popular, setPopular] = useState<any[]>(homeCache?.popular || []);
|
const [popular, setPopular] = useState<any[]>(homeCache?.popular || []);
|
||||||
const [newest, setNewest] = useState<any[]>(homeCache?.newest || []);
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [heroIndex, setHeroIndex] = useState(0);
|
||||||
|
const [heroSubImageIndex, setHeroSubImageIndex] = useState(0);
|
||||||
|
|
||||||
const popularRef = useRef<HTMLElement | null>(null);
|
const popularRef = useRef<HTMLElement | null>(null);
|
||||||
const newestRef = 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' | 'all', data: any[]) => {
|
||||||
|
|
||||||
const saveSection = (key: 'popular' | 'newest', data: any[]) => {
|
|
||||||
if (key === 'popular') setPopular(data);
|
if (key === 'popular') setPopular(data);
|
||||||
if (key === 'newest') setNewest(data);
|
if (key === 'newest') setNewest(data);
|
||||||
|
if (key === 'all') setAllPlugins(data);
|
||||||
|
|
||||||
homeCache = {
|
homeCache = {
|
||||||
popular: key === 'popular' ? data : homeCache?.popular || [],
|
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;
|
fetched.current[key] = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetched.current.popular && fetched.current.newest) return;
|
if (fetched.current.popular && fetched.current.newest && fetched.current.all) return;
|
||||||
|
|
||||||
const observers: IntersectionObserver[] = [];
|
const loadSections = async () => {
|
||||||
|
setLoading(true);
|
||||||
const createObserver = (el: HTMLElement | null, key: 'popular' | 'newest', params: any) => {
|
try {
|
||||||
if (!el || fetched.current[key]) return;
|
const [popRes, newRes, allRes] = await Promise.all([
|
||||||
const obs = new IntersectionObserver(async (entries, observer) => {
|
api.get('/plugins', { params: { sort: 'downloads' } }),
|
||||||
for (const entry of entries) {
|
api.get('/plugins', { params: { sort: 'newest' } }),
|
||||||
if (entry.isIntersecting) {
|
api.get('/plugins')
|
||||||
fetched.current[key] = true; // prevent duplicate triggers
|
]);
|
||||||
try {
|
saveSection('popular', Array.isArray(popRes.data) ? popRes.data : []);
|
||||||
setLoading(true);
|
saveSection('newest', Array.isArray(newRes.data) ? newRes.data : []);
|
||||||
const res = await api.get('/plugins', { params });
|
saveSection('all', Array.isArray(allRes.data) ? allRes.data : []);
|
||||||
const list = Array.isArray(res.data) ? res.data : [];
|
} catch (err) {
|
||||||
saveSection(key, list);
|
console.error('Fetch error for home sections:', err);
|
||||||
} catch (err) {
|
} finally {
|
||||||
console.error('Fetch error for', key, err);
|
setLoading(false);
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { rootMargin: '200px' });
|
|
||||||
|
|
||||||
obs.observe(el);
|
|
||||||
observers.push(obs);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure refs are attached before creating observers
|
loadSections();
|
||||||
requestAnimationFrame(() => {
|
|
||||||
createObserver(popularRef.current, 'popular', { sort: 'downloads', limit: 8 });
|
|
||||||
createObserver(newestRef.current, 'newest', { sort: 'newest', limit: 12 });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observers.forEach(o => o.disconnect());
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="loading-state">
|
<div className="loading-state">
|
||||||
<div className="spinner"></div>
|
<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."
|
description="Discover the best WASM-powered Minecraft plugins at Pumpkin Market. Performance, security, and variety in one place."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="hero">
|
{activeFeatured && (
|
||||||
<h1>{t('welcome')}</h1>
|
<section className="featured-showcase-container">
|
||||||
<p>Explore powerful plugins for your PumpkinMC experience.</p>
|
<div className="featured-showcase-main">
|
||||||
</section>
|
<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">
|
<div className="container" id="browse">
|
||||||
|
|
||||||
@ -124,7 +238,20 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="empty-state">
|
||||||
<p>No plugins found. Check back later!</p>
|
<p>No plugins found. Check back later!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -332,9 +332,9 @@ const PluginDetail = () => {
|
|||||||
|
|
||||||
// ── Download / purchase handler ──────────────────────────────────────────
|
// ── Download / purchase handler ──────────────────────────────────────────
|
||||||
const handleDownload = async () => {
|
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
|
// Already owns it — download directly, no checkout
|
||||||
if (ownsPlugin) {
|
if (ownsPlugin) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -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', 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' }}>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user