437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Search, Store } from 'lucide-react';
|
|
import Home from './pages/Home';
|
|
import PluginDetail from './pages/PluginDetail';
|
|
import LoginPage from './pages/auth/LoginPage';
|
|
import RegisterPage from './pages/auth/RegisterPage';
|
|
import ForgotPasswordPage from './pages/auth/ForgotPasswordPage';
|
|
import ResetPasswordPage from './pages/auth/ResetPasswordPage';
|
|
import VerifyEmailPage from './pages/auth/VerifyEmailPage';
|
|
import ConfirmEmailChangePage from './pages/auth/ConfirmEmailChangePage';
|
|
import CheckEmailPage from './pages/auth/CheckEmailPage';
|
|
import ProfilePage from './pages/ProfilePage';
|
|
import AuthorProfilePage from './pages/AuthorProfilePage';
|
|
import SearchResultsPage from './pages/SearchResultsPage';
|
|
import DashboardAudience from './pages/dashboard/DashboardAudience';
|
|
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
|
|
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
|
|
import AddPlugin from './pages/dashboard/plugin/AddPlugin';
|
|
import TermsOfServicePage from './pages/legal/TermsOfServicePage';
|
|
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
|
|
import GuidelinesPage from './pages/legal/GuidelinesPage';
|
|
import DeveloperTermsPage from './pages/legal/DeveloperTermsPage';
|
|
import LegalNoticePage from './pages/legal/LegalNoticePage';
|
|
import AdminPanel from './pages/admin/AdminPanel';
|
|
import { CookieBanner } from './components/CookieBanner';
|
|
import { openCookiePreferencesModal } from './utils/consent';
|
|
import './App.css';
|
|
|
|
import api from './api';
|
|
|
|
// --- Scroll To Top Helper ---
|
|
const ScrollToTop = () => {
|
|
const { pathname } = useLocation();
|
|
useEffect(() => {
|
|
window.scrollTo(0, 0);
|
|
}, [pathname]);
|
|
return null;
|
|
};
|
|
|
|
// --- Auth Context ---
|
|
const AuthContext = createContext<any>(null);
|
|
export const useAuth = () => useContext(AuthContext);
|
|
|
|
const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|
const [user, setUser] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Helper to decode JWT payload safely
|
|
const decodeToken = (token: string) => {
|
|
try {
|
|
const base64Url = token.split('.')[1];
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
}).join(''));
|
|
return JSON.parse(jsonPayload);
|
|
} catch (e) {
|
|
console.error('Failed to decode token', e);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem('token');
|
|
if (token) {
|
|
const payload = decodeToken(token);
|
|
if (payload && payload.exp * 1000 > Date.now()) {
|
|
setUser(payload);
|
|
} else {
|
|
localStorage.removeItem('token');
|
|
}
|
|
}
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
const login = (token: string) => {
|
|
localStorage.setItem('token', token);
|
|
const payload = decodeToken(token);
|
|
setUser(payload);
|
|
};
|
|
|
|
const logout = () => {
|
|
localStorage.removeItem('token');
|
|
setUser(null);
|
|
};
|
|
|
|
const refreshUser = async () => {
|
|
try {
|
|
const res = await api.get('/user/me');
|
|
if (res.data.token) {
|
|
login(res.data.token);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to refresh user', e);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, login, logout, loading, refreshUser }}>
|
|
{!loading && children}
|
|
</AuthContext.Provider>
|
|
);
|
|
};
|
|
|
|
// --- Protected Route ---
|
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|
const { user } = useAuth();
|
|
if (!user) {
|
|
return <Navigate to="/login" />;
|
|
}
|
|
return children;
|
|
};
|
|
|
|
// --- Staff Route (Admin & Moderator) ---
|
|
const StaffRoute = ({ children }: { children: React.ReactNode }) => {
|
|
const { user } = useAuth();
|
|
if (!user || (user.role !== 'admin' && user.role !== 'moderator')) {
|
|
return <Navigate to="/" />;
|
|
}
|
|
return children;
|
|
};
|
|
|
|
// --- Main App & Layout ---
|
|
const App = () => (
|
|
<Router>
|
|
<AuthProvider>
|
|
<ScrollToTop />
|
|
<CookieBanner />
|
|
<Routes>
|
|
{/* All marketplace routes share MainLayout (original navbar + footer) */}
|
|
<Route element={<MainLayout />}>
|
|
<Route path="/" element={<Home />} />
|
|
<Route path="plugin/:id" element={<PluginDetail />} />
|
|
<Route path="plugin/:id-:slug" element={<PluginDetail />} />
|
|
<Route path="profile/:username" element={<AuthorProfilePage />} />
|
|
<Route path="search" element={<SearchResultsPage />} />
|
|
<Route path="terms" element={<TermsOfServicePage />} />
|
|
<Route path="privacy" element={<PrivacyPolicyPage />} />
|
|
<Route path="guidelines" element={<GuidelinesPage />} />
|
|
<Route path="developer-terms" element={<DeveloperTermsPage />} />
|
|
<Route path="legal/developer-terms" element={<DeveloperTermsPage />} />
|
|
<Route path="legal-notice" element={<LegalNoticePage />} />
|
|
<Route path="impressum" element={<LegalNoticePage />} />
|
|
<Route path="login" element={<LoginPage />} />
|
|
<Route path="register" element={<RegisterPage />} />
|
|
<Route path="forgot-password" element={<ForgotPasswordPage />} />
|
|
<Route path="reset-password" element={<ResetPasswordPage />} />
|
|
<Route path="verify-email" element={<VerifyEmailPage />} />
|
|
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
|
<Route path="check-email" element={<CheckEmailPage />} />
|
|
<Route path="staff" element={<StaffRoute><AdminPanel /></StaffRoute>} />
|
|
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
|
</Route>
|
|
|
|
{/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */}
|
|
<Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
|
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
|
<Route path="audience" element={<DashboardAudience />} />
|
|
<Route path="plugins" element={<DashboardPlugins />} />
|
|
<Route path="add-plugin" element={<AddPlugin />} />
|
|
<Route path="manage-plugin/:id" element={<ManagePlugin />} />
|
|
</Route>
|
|
</Routes>
|
|
</AuthProvider>
|
|
</Router>
|
|
);
|
|
|
|
const MainLayout = () => {
|
|
const { user } = useAuth();
|
|
return (
|
|
<>
|
|
<Navbar user={user} />
|
|
<main><Outlet /></main>
|
|
<Footer />
|
|
</>
|
|
);
|
|
};
|
|
|
|
import './pages/dashboard/Dashboard.css';
|
|
|
|
const DeveloperStudioNavbar = ({ user }: any) => {
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
|
|
|
return (
|
|
<nav className="navbar">
|
|
<div className="logo" style={{ display: 'flex', alignItems: 'center' }}>
|
|
<Link to="/dashboard/plugins" onClick={() => setIsMenuOpen(false)}>
|
|
<img src="/icon.png" alt="Market Logo" style={{ height: '50px', marginRight: '0px', verticalAlign: 'middle' }} />
|
|
<span>STUDIO</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<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="/dashboard/plugins" onClick={() => setIsMenuOpen(false)}>Plugins</NavLink>
|
|
<NavLink to="/dashboard/audience" onClick={() => setIsMenuOpen(false)}>Audience</NavLink>
|
|
<NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>
|
|
{user && (user.role === 'admin' || user.role === 'moderator') && (
|
|
<NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>
|
|
)}
|
|
</div>
|
|
|
|
<div className="nav-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '1.25rem' }}>
|
|
<Link
|
|
to="/"
|
|
onClick={() => setIsMenuOpen(false)}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
color: 'var(--primary)',
|
|
textDecoration: 'none',
|
|
fontWeight: 600,
|
|
fontSize: '0.85rem',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.03em',
|
|
padding: '6px 14px',
|
|
borderRadius: '20px',
|
|
background: 'rgba(255, 117, 24, 0.1)',
|
|
border: '1px solid rgba(255, 117, 24, 0.3)',
|
|
transition: 'all 0.2s ease'
|
|
}}
|
|
onMouseEnter={e => {
|
|
e.currentTarget.style.background = 'rgba(255, 117, 24, 0.2)';
|
|
e.currentTarget.style.borderColor = 'var(--primary)';
|
|
}}
|
|
onMouseLeave={e => {
|
|
e.currentTarget.style.background = 'rgba(255, 117, 24, 0.1)';
|
|
e.currentTarget.style.borderColor = 'rgba(255, 117, 24, 0.3)';
|
|
}}
|
|
>
|
|
<Store size={14} />
|
|
<span>Marketplace</span>
|
|
</Link>
|
|
|
|
{user ? (
|
|
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
|
|
) : (
|
|
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>Login</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
const DashboardLayout = () => {
|
|
const { user } = useAuth();
|
|
return (
|
|
<div className="dashboard-container">
|
|
<DeveloperStudioNavbar user={user} />
|
|
<div className="dashboard-content">
|
|
<Outlet />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// --- Components ---
|
|
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="/" onClick={() => setIsMenuOpen(false)}>
|
|
<img src="/icon.png" alt="Market Logo" style={{ height: '50px', marginRight: '0px', verticalAlign: 'middle' }} />
|
|
<span>MARKET</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<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 && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && (
|
|
<NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>
|
|
)}
|
|
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
|
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
|
</div>
|
|
|
|
{/* ── Center Search Bar ── */}
|
|
<div className="nav-search-center">
|
|
<div className="nav-search-input-wrap">
|
|
<Search size={16} className="nav-search-icon" />
|
|
<input
|
|
type="text"
|
|
placeholder={t('search')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={handleSearch}
|
|
/>
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|
|
|
|
const Footer = () => (
|
|
<footer style={{ padding: '16px 24px' }}>
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '16px',
|
|
flexWrap: 'wrap',
|
|
}}>
|
|
{/* Logo inline */}
|
|
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: '6px', textDecoration: 'none', opacity: 0.6 }}>
|
|
<img src="/icon.png" alt="Market Logo" style={{ height: '20px', verticalAlign: 'middle' }} />
|
|
</Link>
|
|
|
|
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
|
|
|
<p style={{ fontSize: '12px', opacity: 0.4, margin: 0 }}>
|
|
© 2026 PumpkinMC
|
|
</p>
|
|
|
|
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
|
|
|
<Link
|
|
to="/terms"
|
|
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
|
|
>
|
|
Terms
|
|
</Link>
|
|
|
|
<Link
|
|
to="/privacy"
|
|
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
|
|
>
|
|
Privacy
|
|
</Link>
|
|
|
|
<Link
|
|
to="/guidelines"
|
|
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
|
|
>
|
|
Guidelines
|
|
</Link>
|
|
|
|
<Link
|
|
to="/developer-terms"
|
|
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
|
|
>
|
|
Developer Terms
|
|
</Link>
|
|
|
|
<Link
|
|
to="/legal-notice"
|
|
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
|
|
>
|
|
Legal Notice
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={openCookiePreferencesModal}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
fontFamily: 'inherit',
|
|
fontSize: '12px',
|
|
color: 'inherit',
|
|
opacity: 0.4,
|
|
textDecoration: 'none'
|
|
}}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
|
|
>
|
|
Cookie Preferences
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
);
|
|
|
|
export default App; |