chore: improve ui
This commit is contained in:
parent
2eabb9b7a5
commit
834ea2aa39
6
public/robots.txt
Normal file
6
public/robots.txt
Normal file
@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin
|
||||
Disallow: /dashboard
|
||||
|
||||
Sitemap: https://pumpkinmc.net/sitemap.xml
|
||||
82
src/App.css
82
src/App.css
@ -108,16 +108,24 @@ h1, h2, h3 { font-weight: 900; letter-spacing: -1px; }
|
||||
.navbar {
|
||||
background: rgba(10, 10, 10, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 1.5rem 2.5rem;
|
||||
padding: 1rem 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 1000;
|
||||
}
|
||||
.navbar a { text-decoration: none !important; }
|
||||
.logo { font-size: 1.5rem; font-weight: 900; text-transform: uppercase; letter-spacing: -1px; text-decoration: none; color: var(--text); }
|
||||
.logo { font-size: 1.5rem; font-weight: 900; text-transform: uppercase; letter-spacing: -1px; text-decoration: none; color: var(--text); z-index: 1001; }
|
||||
.logo span { color: var(--primary); }
|
||||
.nav-links { flex-grow: 1; margin-left: 3rem; }
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.nav-links { margin-left: 3rem; }
|
||||
.nav-links a { margin: 0 1rem; text-decoration: none; color: var(--text-muted); font-weight: 600; font-size: 0.9rem; text-transform: uppercase; transition: color 0.2s; }
|
||||
.nav-links a.active, .nav-links a:hover { color: var(--text); text-decoration: none !important; }
|
||||
.nav-search {
|
||||
@ -142,10 +150,74 @@ h1, h2, h3 { font-weight: 900; letter-spacing: -1px; }
|
||||
.nav-search input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.nav-actions { display: flex; align-items: center; gap: 1.5rem; }
|
||||
.nav-actions { display: flex; align-items: center; gap: 1rem; }
|
||||
.nav-user-link { font-weight: 700; text-decoration: none; color: var(--text); }
|
||||
.balance { font-family: var(--font-mono); color: var(--success); font-weight: 700; }
|
||||
footer { text-align: center; padding: 4rem 2rem; border-top: 1px solid var(--border); color: var(--text-muted); }
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.navbar { padding: 1rem 1.5rem; }
|
||||
.nav-links { margin-left: 1.5rem; }
|
||||
.nav-links a { margin: 0 0.5rem; }
|
||||
.nav-search { margin: 0 1rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-toggle { display: block; }
|
||||
.nav-content {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -100%;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
height: 100vh;
|
||||
background: var(--surface);
|
||||
flex-direction: column;
|
||||
padding: 80px 2rem 2rem;
|
||||
transition: right 0.3s ease;
|
||||
box-shadow: -5px 0 15px rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.nav-content.open { right: 0; }
|
||||
.nav-links { margin: 0 0 2rem 0; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.nav-links a { margin: 0; font-size: 1.1rem; }
|
||||
.nav-search { margin: 0 0 2rem 0; max-width: none; width: 100%; }
|
||||
.nav-actions { flex-direction: column; width: 100%; gap: 1rem; }
|
||||
.nav-actions .btn { width: 100%; }
|
||||
|
||||
.plugin-detail-layout { grid-template-columns: 1fr; gap: 2rem; }
|
||||
.sidebar-widget { position: static; }
|
||||
.plugin-title { font-size: 2.5rem; }
|
||||
.plugin-detail-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
|
||||
|
||||
.container { padding: 2rem 1rem; }
|
||||
.hero { padding: 4rem 1rem; }
|
||||
.hero h1 { font-size: 2.5rem; }
|
||||
.hero-stats { flex-direction: column; gap: 1.5rem; margin-top: 3rem; }
|
||||
|
||||
.review-summary-grid { grid-template-columns: 1fr; gap: 1.5rem; }
|
||||
.review-avg-block { border-right: none; border-bottom: 1px solid var(--border); padding-right: 0; padding-bottom: 1.5rem; }
|
||||
|
||||
.profile-layout { flex-direction: column; }
|
||||
.profile-nav { position: static; min-width: 0; width: 100%; margin-bottom: 1rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.plugin-grid { grid-template-columns: 1fr; }
|
||||
.library-grid { grid-template-columns: 1fr; }
|
||||
.plugin-title { font-size: 2rem; }
|
||||
.auth-card, .settings-card { padding: 2rem 1.5rem; }
|
||||
}
|
||||
|
||||
/* --- Hero Section --- */
|
||||
.hero {
|
||||
|
||||
69
src/App.tsx
69
src/App.tsx
@ -168,48 +168,65 @@ const DashboardLayout = () => {
|
||||
const Navbar = ({ user }: any) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
setSearchQuery('');
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="logo" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Link to="/">
|
||||
<Link to="/" onClick={() => setIsMenuOpen(false)}>
|
||||
<img src="/icon.png" alt="Market Logo" style={{ height: '50px', marginRight: '0px', verticalAlign: 'middle' }} />
|
||||
<span>MARKET</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/">{t('home')}</NavLink>
|
||||
{user && <NavLink to="/dashboard">{t('dashboard')}</NavLink>}
|
||||
{user && user.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
||||
</div>
|
||||
<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 ? (
|
||||
<>
|
||||
<NavLink to="/settings" className="nav-user-link">{user.username}</NavLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="btn btn-secondary">{t('login')}</Link>
|
||||
<Link to="/register" className="btn">{t('register')}</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="mobile-menu-toggle" onClick={toggleMenu} aria-label="Toggle menu">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{isMenuOpen ? (
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
) : (
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/" onClick={() => setIsMenuOpen(false)}>{t('home')}</NavLink>
|
||||
{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>
|
||||
<div className="nav-actions">
|
||||
{user ? (
|
||||
<>
|
||||
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('login')}</Link>
|
||||
<Link to="/register" className="btn" onClick={() => setIsMenuOpen(false)}>{t('register')}</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
96
src/components/SEO.tsx
Normal file
96
src/components/SEO.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface SEOProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
canonical?: string;
|
||||
ogType?: string;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const SEO = ({
|
||||
title,
|
||||
description = "Explore powerful, performance-optimized Minecraft plugins at Pumpkin Market. The one-stop marketplace for WASM-powered MC plugins.",
|
||||
keywords = "minecraft, plugins, wasm, marketplace, mc plugins, development",
|
||||
canonical,
|
||||
ogType = "website",
|
||||
ogImage = "/icon.png",
|
||||
}: SEOProps) => {
|
||||
const location = useLocation();
|
||||
const siteName = "Pumpkin Market";
|
||||
const fullTitle = title ? `${title} | ${siteName}` : `${siteName} - WASM Minecraft Plugin Marketplace`;
|
||||
const url = canonical || `https://pumpkinmc.net${location.pathname}`;
|
||||
|
||||
useEffect(() => {
|
||||
// Update Title
|
||||
document.title = fullTitle;
|
||||
|
||||
// Update Meta Description
|
||||
let metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta');
|
||||
metaDescription.setAttribute('name', 'description');
|
||||
document.head.appendChild(metaDescription);
|
||||
}
|
||||
metaDescription.setAttribute('content', description);
|
||||
|
||||
// Update Meta Keywords
|
||||
let metaKeywords = document.querySelector('meta[name="keywords"]');
|
||||
if (!metaKeywords) {
|
||||
metaKeywords = document.createElement('meta');
|
||||
metaKeywords.setAttribute('name', 'keywords');
|
||||
document.head.appendChild(metaKeywords);
|
||||
}
|
||||
metaKeywords.setAttribute('content', keywords);
|
||||
|
||||
// Update Canonical
|
||||
let linkCanonical = document.querySelector('link[rel="canonical"]');
|
||||
if (!linkCanonical) {
|
||||
linkCanonical = document.createElement('link');
|
||||
linkCanonical.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(linkCanonical);
|
||||
}
|
||||
linkCanonical.setAttribute('href', url);
|
||||
|
||||
// Update Open Graph tags
|
||||
const updateOG = (property: string, content: string) => {
|
||||
let tag = document.querySelector(`meta[property="${property}"]`);
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta');
|
||||
tag.setAttribute('property', property);
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
tag.setAttribute('content', content);
|
||||
};
|
||||
|
||||
updateOG('og:title', fullTitle);
|
||||
updateOG('og:description', description);
|
||||
updateOG('og:url', url);
|
||||
updateOG('og:type', ogType);
|
||||
updateOG('og:image', ogImage);
|
||||
updateOG('og:site_name', siteName);
|
||||
|
||||
// Update Twitter Card tags
|
||||
const updateTwitter = (name: string, content: string) => {
|
||||
let tag = document.querySelector(`meta[name="${name}"]`);
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta');
|
||||
tag.setAttribute('name', name);
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
tag.setAttribute('content', content);
|
||||
};
|
||||
|
||||
updateTwitter('twitter:card', 'summary_large_image');
|
||||
updateTwitter('twitter:title', fullTitle);
|
||||
updateTwitter('twitter:description', description);
|
||||
updateTwitter('twitter:image', ogImage);
|
||||
|
||||
}, [fullTitle, description, keywords, url, ogType, ogImage]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
export default SEO;
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import SEO from '../components/SEO';
|
||||
|
||||
const Home = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
@ -12,10 +13,13 @@ const Home = () => {
|
||||
if (hasFetched.current) return;
|
||||
api.get('/plugins', { params: { q: '', sort: 'newest' } })
|
||||
.then(res => {
|
||||
setPlugins(res.data);
|
||||
setPlugins(Array.isArray(res.data) ? res.data : []);
|
||||
hasFetched.current = true;
|
||||
})
|
||||
.catch(err => console.error('Fetch error:', err));
|
||||
.catch(err => {
|
||||
console.error('Fetch error:', err);
|
||||
setPlugins([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getDescription = (translatedStr: string) => {
|
||||
@ -59,6 +63,10 @@ const Home = () => {
|
||||
|
||||
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;
|
||||
|
||||
@ -5,6 +5,7 @@ import { useAuth } from '../App';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SEO from '../components/SEO';
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'Malware or malicious code',
|
||||
@ -138,12 +139,12 @@ const PluginDetail = () => {
|
||||
}, [plugin, selectedLanguage]);
|
||||
|
||||
const hasReviewed = useMemo(() => {
|
||||
if (!user || !plugin?.reviews) return false;
|
||||
if (!user || !plugin?.reviews || !Array.isArray(plugin.reviews)) return false;
|
||||
return plugin.reviews.some((r: any) => r.username === user.username);
|
||||
}, [plugin?.reviews, user]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!plugin?.reviews || plugin.reviews.length === 0) return null;
|
||||
if (!plugin?.reviews || !Array.isArray(plugin.reviews) || plugin.reviews.length === 0) return null;
|
||||
const total = plugin.reviews.length;
|
||||
const distribution = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
|
||||
let sum = 0;
|
||||
@ -257,8 +258,39 @@ const PluginDetail = () => {
|
||||
return 'Download Latest WASM';
|
||||
})();
|
||||
|
||||
const productSchema = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": plugin.name,
|
||||
"operatingSystem": "Minecraft",
|
||||
"applicationCategory": "GameApplication",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00",
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"aggregateRating": stats ? {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": stats.avg,
|
||||
"reviewCount": stats.total
|
||||
} : undefined,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": plugin.dev_name
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<SEO
|
||||
title={plugin.name}
|
||||
description={currentDescription.substring(0, 160)}
|
||||
ogType="product"
|
||||
ogImage={plugin.preview_path || "/icon.png"}
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(productSchema)}
|
||||
</script>
|
||||
|
||||
{/* ── Payment success banner ── */}
|
||||
{showPaymentSuccess && (
|
||||
@ -301,7 +333,7 @@ const PluginDetail = () => {
|
||||
<div className="plugin-main-content">
|
||||
|
||||
{/* SCREENSHOTS */}
|
||||
{plugin.screenshots && plugin.screenshots.length > 0 && (
|
||||
{Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && (
|
||||
<div className="screenshot-gallery">
|
||||
<img
|
||||
src={mainScreenshot || plugin.screenshots[0].path}
|
||||
@ -366,7 +398,7 @@ const PluginDetail = () => {
|
||||
)}
|
||||
|
||||
<div className="review-list-premium">
|
||||
{plugin.reviews?.map((r: any) => (
|
||||
{Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => (
|
||||
<div key={r.id} className="review-item-premium">
|
||||
<div className="review-item-top">
|
||||
<div className={`review-avatar ${getAvatarClass(r.username)}`}>
|
||||
|
||||
@ -53,7 +53,7 @@ const ProfilePage = () => {
|
||||
setLibraryLoading(true);
|
||||
setLibraryError(null);
|
||||
api.get('/user/library')
|
||||
.then(res => setLibrary(res.data))
|
||||
.then(res => setLibrary(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(() => setLibraryError('Failed to load your library. Please try again.'))
|
||||
.finally(() => setLibraryLoading(false));
|
||||
}, [activeTab]);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import SEO from '../components/SEO';
|
||||
|
||||
const SearchResultsPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@ -19,7 +20,7 @@ const SearchResultsPage = () => {
|
||||
const response = await api.get('/plugins', {
|
||||
params: { sort: sort || undefined },
|
||||
});
|
||||
setPlugins(response.data);
|
||||
setPlugins(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching plugins:', err);
|
||||
setError('Failed to load plugins. Please try again.');
|
||||
@ -57,6 +58,10 @@ const SearchResultsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<SEO
|
||||
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></>
|
||||
|
||||
@ -121,3 +121,55 @@
|
||||
padding: 4rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.admin-tabs {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-tabs button {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-table thead {
|
||||
display: none;
|
||||
}
|
||||
.admin-table, .admin-table tbody, .admin-table tr, .admin-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-table tr {
|
||||
margin-bottom: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.admin-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
text-align: right;
|
||||
}
|
||||
.admin-table td:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.admin-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #888);
|
||||
margin-right: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,13 +19,15 @@ const AdminPanel = () => {
|
||||
try {
|
||||
if (activeTab === 'users') {
|
||||
const res = await api.get('/admin/users');
|
||||
setUsers(res.data);
|
||||
setUsers(Array.isArray(res.data) ? res.data : []);
|
||||
} else {
|
||||
const res = await api.get('/admin/plugins');
|
||||
setPlugins(res.data);
|
||||
setPlugins(Array.isArray(res.data) ? res.data : []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch data');
|
||||
setUsers([]);
|
||||
setPlugins([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -92,13 +94,13 @@ const AdminPanel = () => {
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.id}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.email}</td>
|
||||
<td><span className={`role-badge ${u.role}`}>{u.role}</span></td>
|
||||
<td>{u.stripe_ready ? '✅' : '❌'}</td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<td data-label="ID">{u.id}</td>
|
||||
<td data-label="Username">{u.username}</td>
|
||||
<td data-label="Email">{u.email}</td>
|
||||
<td data-label="Role"><span className={`role-badge ${u.role}`}>{u.role}</span></td>
|
||||
<td data-label="Stripe">{u.stripe_ready ? '✅' : '❌'}</td>
|
||||
<td data-label="Created">{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td data-label="Actions">
|
||||
<button className="btn-delete" onClick={() => deleteUser(u.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -122,14 +124,14 @@ const AdminPanel = () => {
|
||||
<tbody>
|
||||
{plugins.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td>{p.name}</td>
|
||||
<td><span className={`type-badge ${p.type}`}>{p.type}</span></td>
|
||||
<td>€{(p.price_cents / 100).toFixed(2)}</td>
|
||||
<td>{p.dev_name}</td>
|
||||
<td>{p.downloads}</td>
|
||||
<td>{p.views}</td>
|
||||
<td>
|
||||
<td data-label="ID">{p.id}</td>
|
||||
<td data-label="Name">{p.name}</td>
|
||||
<td data-label="Type"><span className={`type-badge ${p.type}`}>{p.type}</span></td>
|
||||
<td data-label="Price">€{(p.price_cents / 100).toFixed(2)}</td>
|
||||
<td data-label="Developer">{p.dev_name}</td>
|
||||
<td data-label="Downloads">{p.downloads}</td>
|
||||
<td data-label="Views">{p.views}</td>
|
||||
<td data-label="Actions">
|
||||
<button className="btn-delete" onClick={() => deletePlugin(p.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -173,9 +173,82 @@
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--dash-border);
|
||||
}
|
||||
.switch-label {
|
||||
font-weight: 500;
|
||||
color: var(--dash-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-nav {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.dashboard-nav a {
|
||||
margin-right: 1.5rem;
|
||||
padding: 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.dashboard-grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.kpi-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-grid-v2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.dashboard-top-nav {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Responsive Tables */
|
||||
.data-table thead {
|
||||
display: none; /* Hide headers on mobile */
|
||||
}
|
||||
.data-table, .data-table tbody, .data-table tr, .data-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.data-table tr {
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--dash-surface);
|
||||
border: 1px solid var(--dash-border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
|
||||
}
|
||||
.data-table td {
|
||||
text-align: left !important;
|
||||
padding: 0.75rem 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--dash-surface-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.data-table td:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.data-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--dash-text-muted);
|
||||
}
|
||||
}
|
||||
.switch {
|
||||
position: relative;
|
||||
|
||||
@ -59,23 +59,23 @@ const DashboardPlugins = () => {
|
||||
<tbody>
|
||||
{plugins.map((p: any) => (
|
||||
<tr key={p.id}>
|
||||
<td>
|
||||
<td data-label="Name">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<strong>{p.name}</strong>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)' }}>ID: {p.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono">{p.downloads.toLocaleString()}</td>
|
||||
<td className="mono" style={{ color: 'var(--success)' }}>
|
||||
<td data-label="Downloads" className="mono">{p.downloads.toLocaleString()}</td>
|
||||
<td data-label="Earnings" className="mono" style={{ color: 'var(--success)' }}>
|
||||
+${p.earnings.toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Dates">
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted)' }}>
|
||||
<div>Created: {formatDate(p.createdAt)}</div>
|
||||
<div>Updated: {formatDate(p.updatedAt)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<td data-label="Actions" style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
{/* View on Marketplace */}
|
||||
<button
|
||||
|
||||
@ -18,14 +18,15 @@ export const useAnalytics = () => {
|
||||
});
|
||||
|
||||
// Ensure we are accessing the correct keys from your Express res.json
|
||||
setTimeSeries(res.data.timeSeries || []);
|
||||
setMapData(res.data.mapData || []);
|
||||
setBreakdowns(res.data.breakdowns || []);
|
||||
setTimeSeries(Array.isArray(res.data.timeSeries) ? res.data.timeSeries : []);
|
||||
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
||||
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
||||
} catch (error) {
|
||||
console.error("Analytics Fetch Error:", error);
|
||||
// Reset data on error so the UI doesn't show stale/broken stats
|
||||
setTimeSeries([]);
|
||||
setMapData([]);
|
||||
setBreakdowns([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -337,7 +337,7 @@ const PrivacyPolicyPage = () => {
|
||||
/* ── Sidebar ── */
|
||||
.pp-sidebar {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
@ -386,7 +386,7 @@ const PrivacyPolicyPage = () => {
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 16px;
|
||||
transition: border-color 0.2s;
|
||||
scroll-margin-top: 80px;
|
||||
scroll-margin-top: 100px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
@ -288,7 +288,7 @@ const TermsOfServicePage = () => {
|
||||
/* ── Sidebar ── */
|
||||
.tos-sidebar {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
@ -337,9 +337,9 @@ const TermsOfServicePage = () => {
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 16px;
|
||||
transition: border-color 0.2s;
|
||||
scroll-margin-top: 80px;
|
||||
scroll-margin-top: 100px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tos-section:hover { border-color: rgba(255, 107, 0, 0.2); }
|
||||
|
||||
.tos-section.active { border-color: rgba(255, 107, 0, 0.35); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user