388 lines
14 KiB
TypeScript
388 lines
14 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, Package, Globe, PlusCircle } 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 AdminPanel from './pages/admin/AdminPanel';
|
|
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 />
|
|
<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="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 (
|
|
<header className="dashboard-app-header">
|
|
<div className="dashboard-app-header-inner">
|
|
{/* Brand */}
|
|
<Link to="/dashboard/plugins" className="dashboard-app-brand" onClick={() => setIsMenuOpen(false)}>
|
|
<img src="/icon.png" alt="Market Logo" className="dashboard-app-logo" />
|
|
<span className="dashboard-app-title">DEVELOPER <span>STUDIO</span></span>
|
|
</Link>
|
|
|
|
{/* Mobile menu toggle button */}
|
|
<button className="dashboard-mobile-toggle" onClick={toggleMenu} aria-label="Toggle navigation menu">
|
|
<svg width="22" height="22" 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>
|
|
|
|
{/* Navigation & Actions */}
|
|
<div className={`dashboard-app-nav-wrap ${isMenuOpen ? 'open' : ''}`}>
|
|
<nav className="dashboard-app-nav">
|
|
<NavLink
|
|
to="/dashboard/plugins"
|
|
className={({ isActive }) => `dashboard-app-nav-link ${isActive ? 'active' : ''}`}
|
|
onClick={() => setIsMenuOpen(false)}
|
|
>
|
|
<Package size={16} />
|
|
<span>Plugins</span>
|
|
</NavLink>
|
|
<NavLink
|
|
to="/dashboard/audience"
|
|
className={({ isActive }) => `dashboard-app-nav-link ${isActive ? 'active' : ''}`}
|
|
onClick={() => setIsMenuOpen(false)}
|
|
>
|
|
<Globe size={16} />
|
|
<span>Audience</span>
|
|
</NavLink>
|
|
<NavLink
|
|
to="/dashboard/add-plugin"
|
|
className={({ isActive }) => `dashboard-app-nav-link ${isActive ? 'active' : ''}`}
|
|
onClick={() => setIsMenuOpen(false)}
|
|
>
|
|
<PlusCircle size={16} />
|
|
<span>Publish</span>
|
|
</NavLink>
|
|
</nav>
|
|
|
|
<div className="dashboard-app-actions">
|
|
<Link to="/" className="dashboard-action-link store-link" onClick={() => setIsMenuOpen(false)} title="Return to Marketplace">
|
|
<Store size={15} />
|
|
<span>Marketplace</span>
|
|
</Link>
|
|
{user && (user.role === 'admin' || user.role === 'moderator') && (
|
|
<NavLink to="/staff" className="dashboard-action-link" onClick={() => setIsMenuOpen(false)}>
|
|
<span>Staff</span>
|
|
</NavLink>
|
|
)}
|
|
{user && (
|
|
<NavLink to="/settings" className="dashboard-user-link" onClick={() => setIsMenuOpen(false)}>
|
|
<span>{user.username}</span>
|
|
</NavLink>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
|
|
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>
|
|
</div>
|
|
</footer>
|
|
);
|
|
|
|
export default App; |