updated admin panel + side bar

This commit is contained in:
nvrl committed 2026-04-27 09:23:22 +02:00
1 parent 22fe0674dd
commit 556bae3556
23 files changed
+2217 -637

No files matched your search

+132 -459
View File
@@ -1,5 +1,5 @@
import React, { Suspense, lazy, useEffect, useState } from 'react';
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
import { Routes, Route, Link, useNavigate, Navigate } from 'react-router-dom';
import {
Typography,
Button,
@@ -9,15 +9,7 @@ import {
Menu,
MenuItem,
SwipeableDrawer,
Divider,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Collapse,
CircularProgress,
ListItemAvatar,
Badge,
alpha,
useMediaQuery,
@@ -26,21 +18,7 @@ import {
import { GlassAppBar } from './components/ui';
import MenuIcon from '@mui/icons-material/Menu';
import NotificationsIcon from '@mui/icons-material/Notifications';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
import CollectionsBookmarkOutlinedIcon from '@mui/icons-material/CollectionsBookmarkOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined';
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined';
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined';
import { useTranslation } from 'react-i18next';
import api from './lib/api';
import Footer from './components/Footer';
@@ -50,6 +28,9 @@ import { GlobalSearch } from './app/GlobalSearch';
import { useAuth } from './hooks/useAuth';
import { useTheme } from './hooks/useTheme';
import { useNotifications } from './hooks/useNotifications';
import { useDrawerAnchor } from './hooks/useDrawerAnchor';
import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar';
import { RequireSuperuser } from './components/auth/RequireSuperuser';
import type { Notification } from './contexts/NotificationContext';
import type { CatppuccinTheme } from './theme';
@@ -65,7 +46,14 @@ const Login = lazy(() => import('./pages/Login'));
const Settings = lazy(() => import('./pages/Settings'));
const Support = lazy(() => import('./pages/Support'));
const VerifyEmail = lazy(() => import('./pages/VerifyEmail'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const Forbidden = lazy(() => import('./pages/Forbidden'));
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
const AdminOverview = lazy(() => import('./pages/admin/AdminOverview'));
const AdminUsers = lazy(() => import('./pages/admin/AdminUsers'));
const AdminBanners = lazy(() => import('./pages/admin/AdminBanners'));
const AdminJobs = lazy(() => import('./pages/admin/AdminJobs'));
const AdminTickets = lazy(() => import('./pages/admin/AdminTickets'));
const AdminConfig = lazy(() => import('./pages/admin/AdminConfig'));
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
@@ -73,20 +61,16 @@ const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
const App: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const muiTheme = useMuiTheme();
const { user, following, loadingUser, logout } = useAuth();
const { user, loadingUser, logout } = useAuth();
const { themeName, handleThemeChange } = useTheme();
const { notifications, markAllRead, markRead } = useNotifications();
const { anchor: mobileAnchor } = useDrawerAnchor();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(null);
const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [catOpen, setCatOpen] = useState(false);
const [followingOpen, setFollowingOpen] = useState(false);
const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true');
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
useEffect(() => {
@@ -104,12 +88,6 @@ const App: React.FC = () => {
document.body.style.color = muiTheme.palette.text.primary;
}, [muiTheme]);
const toggleAdminMode = () => {
const newVal = !adminMode;
setAdminMode(newVal);
localStorage.setItem('admin-mode', String(newVal));
};
const onThemeChange = async (name: CatppuccinTheme) => {
await handleThemeChange(name, !!user);
setAnchorEl(null);
@@ -119,7 +97,7 @@ const App: React.FC = () => {
if (!notif.is_read) await markRead(notif.id);
setNotifAnchorEl(null);
if (notif.notification_type.startsWith('ticket')) {
navigate('/support');
navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support');
} else if (notif.notification_type === 'profile_comment') {
navigate(`/profile/${user?.username}`);
} else if (notif.notification_type === 'follow') {
@@ -129,303 +107,15 @@ const App: React.FC = () => {
}
};
const closeDrawer = () => setDrawerOpen(false);
const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
const drawer = (
<Box
sx={{ width: 300, height: '100%', display: 'flex', flexDirection: 'column' }}
role="presentation"
const hamburger = (
<IconButton
color="inherit"
onClick={() => setDrawerOpen(true)}
aria-label="open navigation"
sx={{ mx: 0.5 }}
>
{user ? (
<Box
component={Link}
to={`/profile/${user.username}`}
onClick={closeDrawer}
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
gap: 1.5,
color: 'inherit',
textDecoration: 'none',
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Avatar
src={user.avatar || undefined}
sx={{
width: 44,
height: 44,
border: '2px solid',
borderColor: 'primary.main',
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user.username}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('nav.profile')}
</Typography>
</Box>
</Box>
) : (
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
<Link to="/" onClick={closeDrawer} style={{ color: 'inherit', textDecoration: 'none' }}>
Holy Flavors Archive
</Link>
</Typography>
</Box>
)}
<List sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
<ListItem disablePadding>
<ListItemButton component={Link} to="/" onClick={closeDrawer} selected={isActive('/')}>
<ListItemIcon sx={{ minWidth: 40 }}>
<HomeOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.home')} />
</ListItemButton>
</ListItem>
{user && (
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/community"
onClick={closeDrawer}
selected={isActive('/community')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<GroupsOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.community')} />
</ListItemButton>
</ListItem>
)}
<ListItemButton
onClick={() => setCatOpen(!catOpen)}
selected={location.pathname.startsWith('/category/')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<CategoryOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.categories')} />
{catOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={catOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{categories.map((cat) => (
<ListItemButton
key={cat.slug}
component={Link}
to={`/category/${cat.slug}`}
onClick={closeDrawer}
selected={location.pathname === `/category/${cat.slug}`}
sx={{ pl: 7 }}
>
<ListItemText primary={t(`categories.${cat.slug}`, { defaultValue: cat.name })} />
</ListItemButton>
))}
</List>
</Collapse>
{user && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton
component={Link}
to={`/profile/${user.username}`}
onClick={closeDrawer}
selected={location.pathname === `/profile/${user.username}`}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<PersonOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.profile')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/dashboard"
onClick={closeDrawer}
selected={isActive('/dashboard')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<CollectionsBookmarkOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.dashboard')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/settings"
onClick={closeDrawer}
selected={isActive('/settings')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.settings')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/support"
onClick={closeDrawer}
selected={isActive('/support')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<HelpOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('support.title')} />
</ListItemButton>
</ListItem>
{user.is_superuser && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton onClick={toggleAdminMode}>
<ListItemIcon sx={{ minWidth: 40 }}>
<ShieldOutlinedIcon color={adminMode ? 'primary' : 'inherit'} />
</ListItemIcon>
<ListItemText
primary={t('admin.adminMode')}
secondary={adminMode ? 'ON' : 'OFF'}
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
/>
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/admin-panel"
onClick={closeDrawer}
selected={isActive('/admin-panel')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<AdminPanelSettingsOutlinedIcon color="primary" />
</ListItemIcon>
<ListItemText
primary="Admin Panel"
primaryTypographyProps={{ sx: { color: 'primary.main', fontWeight: 700 } }}
/>
</ListItemButton>
</ListItem>
</>
)}
{following.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<ListItemButton onClick={() => setFollowingOpen(!followingOpen)}>
<ListItemIcon sx={{ minWidth: 40 }}>
<PeopleOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>{t('nav.following')}</span>
<Typography variant="caption" color="text.secondary">
{following.length}
</Typography>
</Box>
}
/>
{followingOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={followingOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{following.map((f) => (
<ListItem key={f.id} disablePadding>
<ListItemButton
component={Link}
to={`/profile/${f.username}`}
onClick={closeDrawer}
selected={location.pathname === `/profile/${f.username}`}
sx={{ pl: 5 }}
>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={f.avatar || undefined} sx={{ width: 28, height: 28 }}>
{!f.avatar && f.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={f.username}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Collapse>
</>
)}
</>
)}
{!user && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/login"
onClick={closeDrawer}
selected={isActive('/login')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<LoginOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.login')} />
</ListItemButton>
</ListItem>
</>
)}
</List>
{user && (
<Box sx={{ borderTop: '1px solid', borderColor: 'divider' }}>
<ListItemButton
onClick={() => {
closeDrawer();
logout();
}}
>
<ListItemIcon sx={{ minWidth: 40, color: 'error.main' }}>
<LogoutOutlinedIcon />
</ListItemIcon>
<ListItemText
primary={t('nav.logout')}
primaryTypographyProps={{ sx: { color: 'error.main', fontWeight: 600 } }}
/>
</ListItemButton>
</Box>
)}
</Box>
<MenuIcon />
</IconButton>
);
const notificationsMenu = (
@@ -527,13 +217,14 @@ const App: React.FC = () => {
}}
>
<GlassAppBar>
{isMobile && mobileAnchor === 'left' && hamburger}
<Typography
variant="h6"
component="div"
sx={{
fontWeight: 'bold',
mr: { xs: 0, sm: 2 },
display: { xs: 'none', sm: 'block' },
}}
>
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
@@ -546,50 +237,6 @@ const App: React.FC = () => {
</Link>
</Typography>
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 2 }}>
{user && (
<Button
color="inherit"
component={Link}
to="/community"
sx={{
fontWeight: 'bold',
textTransform: 'none',
fontSize: '1rem',
mr: 2,
}}
>
{t('nav.community')}
</Button>
)}
<Button
color="inherit"
endIcon={<ArrowDropDownIcon />}
onClick={(e) => setCatAnchorEl(e.currentTarget)}
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem' }}
>
{t('nav.categories')}
</Button>
<Menu
anchorEl={catAnchorEl}
open={Boolean(catAnchorEl)}
onClose={() => setCatAnchorEl(null)}
elevation={3}
sx={{ mt: 1 }}
>
{categories.map((cat) => (
<MenuItem
key={cat.slug}
component={Link}
to={`/category/${cat.slug}`}
onClick={() => setCatAnchorEl(null)}
>
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
</MenuItem>
))}
</Menu>
</Box>
<GlobalSearch />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
@@ -599,7 +246,11 @@ const App: React.FC = () => {
{user ? (
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 1 }}>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
<IconButton
color="inherit"
onClick={(e) => setAnchorEl(e.currentTarget)}
aria-label="account menu"
>
<Avatar
src={user.avatar || undefined}
sx={{
@@ -620,41 +271,13 @@ const App: React.FC = () => {
sx={{ mt: 1 }}
>
<MenuItem
component={Link}
to={`/profile/${user.username}`}
onClick={() => setAnchorEl(null)}
onClick={() => {
setAnchorEl(null);
logout();
}}
sx={{ color: 'error.main', gap: 1.5 }}
>
{t('nav.profile')}
</MenuItem>
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>
{t('nav.dashboard')}
</MenuItem>
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>
{t('nav.settings')}
</MenuItem>
<MenuItem component={Link} to="/support" onClick={() => setAnchorEl(null)}>
{t('support.title')}
</MenuItem>
{user.is_superuser && (
<MenuItem
onClick={toggleAdminMode}
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
>
{t('admin.adminMode')}: {adminMode ? 'ON' : 'OFF'}
</MenuItem>
)}
{user.is_superuser && (
<MenuItem
component={Link}
to="/admin-panel"
onClick={() => setAnchorEl(null)}
sx={{ fontWeight: 'bold', color: 'primary.main' }}
>
Admin Panel
</MenuItem>
)}
<Divider />
<MenuItem onClick={logout} sx={{ color: 'error.main' }}>
<LogoutOutlinedIcon fontSize="small" />
{t('nav.logout')}
</MenuItem>
</Menu>
@@ -669,68 +292,118 @@ const App: React.FC = () => {
</>
)}
{isMobile && (
<IconButton color="inherit" onClick={() => setDrawerOpen(true)} sx={{ ml: 1 }}>
<MenuIcon />
</IconButton>
)}
{isMobile && mobileAnchor === 'right' && hamburger}
</Box>
</GlassAppBar>
<SwipeableDrawer
anchor="right"
anchor={mobileAnchor}
open={drawerOpen}
onOpen={() => setDrawerOpen(true)}
onClose={() => setDrawerOpen(false)}
disableBackdropTransition
disableDiscovery
>
{drawer}
<NavSidebar categories={categories} onNavigate={() => setDrawerOpen(false)} />
</SwipeableDrawer>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Suspense
fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
}
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
{!isMobile && (
<Box
component="aside"
sx={{
width: NAV_SIDEBAR_WIDTH,
flexShrink: 0,
position: 'sticky',
top: { xs: 56, sm: 64 },
alignSelf: 'flex-start',
height: { xs: 'calc(100vh - 56px)', sm: 'calc(100vh - 64px)' },
borderRight: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: 1,
}}
>
<NavSidebar categories={categories} />
</Box>
)}
<Box
component="main"
sx={{
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Routes>
<Route path="/" element={<MainPage adminMode={adminMode} />} />
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail adminMode={adminMode} />} />
<Route path="/profile/:username" element={<PublicProfile adminMode={adminMode} />} />
<Route path="/community" element={<CommunityFeed adminMode={adminMode} />} />
<Route path="/about" element={<About />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route
path="/settings"
element={<Settings themeName={themeName} onThemeChange={onThemeChange} />}
/>
<Route path="/support" element={<Support />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/admin-panel" element={<AdminDashboard />} />
<Route path="/admin-panel/user/:id" element={<AdminUserDetail />} />
<Route path="/admin-panel/rating/:id" element={<AdminRatingDetail />} />
<Route path="/admin-panel/reply/:id" element={<AdminReplyDetail />} />
<Route path="/login" element={<Login />} />
<Route
path="*"
element={
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h5">404 - Page Not Found</Typography>
<Button component={Link} to="/" sx={{ mt: 2 }}>
Back to Home
</Button>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Suspense
fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
}
/>
</Routes>
</Suspense>
>
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/community" element={<CommunityFeed />} />
<Route path="/about" element={<About />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route
path="/settings"
element={<Settings themeName={themeName} onThemeChange={onThemeChange} />}
/>
<Route path="/support" element={<Support />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forbidden" element={<Forbidden />} />
<Route
path="/admin-panel"
element={
<RequireSuperuser>
<AdminLayout />
</RequireSuperuser>
}
>
<Route index element={<Navigate to="overview" replace />} />
<Route path="overview" element={<AdminOverview />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} />
<Route path="user/:id" element={<AdminUserDetail />} />
<Route path="banners" element={<AdminBanners />} />
<Route path="jobs" element={<AdminJobs />} />
<Route path="tickets" element={<AdminTickets />} />
<Route path="config" element={<AdminConfig />} />
<Route path="ratings/:id" element={<AdminRatingDetail />} />
<Route path="rating/:id" element={<AdminRatingDetail />} />
<Route path="replies/:id" element={<AdminReplyDetail />} />
<Route path="reply/:id" element={<AdminReplyDetail />} />
</Route>
<Route path="/login" element={<Login />} />
<Route
path="*"
element={
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h5">404 - Page Not Found</Typography>
<Button component={Link} to="/" sx={{ mt: 2 }}>
Back to Home
</Button>
</Box>
}
/>
</Routes>
</Suspense>
</Box>
<Footer />
</Box>
</Box>
<Footer />
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
</Box>
);
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import { useAuth } from '../../hooks/useAuth';
export interface RequireSuperuserProps {
children: ReactNode;
}
export const RequireSuperuser = ({ children }: RequireSuperuserProps) => {
const { user, loadingUser } = useAuth();
const location = useLocation();
if (loadingUser) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
if (!user.is_superuser) {
return <Navigate to="/forbidden" replace />;
}
return <>{children}</>;
};
export default RequireSuperuser;
@@ -0,0 +1,337 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
Avatar,
Box,
Collapse,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
} from '@mui/material';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
import CollectionsBookmarkOutlinedIcon from '@mui/icons-material/CollectionsBookmarkOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined';
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined';
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../hooks/useAuth';
export const NAV_SIDEBAR_WIDTH = 280;
export interface NavSidebarProps {
categories: { name: string; slug: string }[];
onNavigate?: () => void;
}
export const NavSidebar = ({ categories, onNavigate }: NavSidebarProps) => {
const { t } = useTranslation();
const location = useLocation();
const { user, following, logout } = useAuth();
const [catOpen, setCatOpen] = useState(false);
const [followingOpen, setFollowingOpen] = useState(false);
const close = () => onNavigate?.();
const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
return (
<Box
sx={{
width: NAV_SIDEBAR_WIDTH,
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
role="presentation"
>
{user ? (
<Box
component={Link}
to={`/profile/${user.username}`}
onClick={close}
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
gap: 1.5,
color: 'inherit',
textDecoration: 'none',
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Avatar
src={user.avatar || undefined}
sx={{
width: 44,
height: 44,
border: '2px solid',
borderColor: 'primary.main',
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user.username}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('nav.profile')}
</Typography>
</Box>
</Box>
) : (
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
<Link to="/" onClick={close} style={{ color: 'inherit', textDecoration: 'none' }}>
Holy Flavors Archive
</Link>
</Typography>
</Box>
)}
<List sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
<ListItem disablePadding>
<ListItemButton component={Link} to="/" onClick={close} selected={isActive('/')}>
<ListItemIcon sx={{ minWidth: 40 }}>
<HomeOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.home')} />
</ListItemButton>
</ListItem>
{user && (
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/community"
onClick={close}
selected={isActive('/community')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<GroupsOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.community')} />
</ListItemButton>
</ListItem>
)}
<ListItemButton
onClick={() => setCatOpen(!catOpen)}
selected={location.pathname.startsWith('/category/')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<CategoryOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.categories')} />
{catOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={catOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{categories.map((cat) => (
<ListItemButton
key={cat.slug}
component={Link}
to={`/category/${cat.slug}`}
onClick={close}
selected={location.pathname === `/category/${cat.slug}`}
sx={{ pl: 7 }}
>
<ListItemText primary={t(`categories.${cat.slug}`, { defaultValue: cat.name })} />
</ListItemButton>
))}
</List>
</Collapse>
{user && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton
component={Link}
to={`/profile/${user.username}`}
onClick={close}
selected={location.pathname === `/profile/${user.username}`}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<PersonOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.profile')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/dashboard"
onClick={close}
selected={isActive('/dashboard')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<CollectionsBookmarkOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.dashboard')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/settings"
onClick={close}
selected={isActive('/settings')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.settings')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/support"
onClick={close}
selected={isActive('/support')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<HelpOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('support.title')} />
</ListItemButton>
</ListItem>
{user.is_superuser && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/admin-panel"
onClick={close}
selected={isActive('/admin-panel')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<AdminPanelSettingsOutlinedIcon color="primary" />
</ListItemIcon>
<ListItemText
primary={t('admin.panelTitle', { defaultValue: 'Admin Panel' })}
primaryTypographyProps={{ sx: { color: 'primary.main', fontWeight: 700 } }}
/>
</ListItemButton>
</ListItem>
</>
)}
{following.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<ListItemButton onClick={() => setFollowingOpen(!followingOpen)}>
<ListItemIcon sx={{ minWidth: 40 }}>
<PeopleOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>{t('nav.following')}</span>
<Typography variant="caption" color="text.secondary">
{following.length}
</Typography>
</Box>
}
/>
{followingOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={followingOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{following.map((f) => (
<ListItem key={f.id} disablePadding>
<ListItemButton
component={Link}
to={`/profile/${f.username}`}
onClick={close}
selected={location.pathname === `/profile/${f.username}`}
sx={{ pl: 5 }}
>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={f.avatar || undefined} sx={{ width: 28, height: 28 }}>
{!f.avatar && f.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={f.username}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Collapse>
</>
)}
</>
)}
{!user && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/login"
onClick={close}
selected={isActive('/login')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<LoginOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.login')} />
</ListItemButton>
</ListItem>
</>
)}
</List>
{user && (
<Box sx={{ borderTop: '1px solid', borderColor: 'divider' }}>
<ListItemButton
onClick={() => {
close();
logout();
}}
>
<ListItemIcon sx={{ minWidth: 40, color: 'error.main' }}>
<LogoutOutlinedIcon />
</ListItemIcon>
<ListItemText
primary={t('nav.logout')}
primaryTypographyProps={{ sx: { color: 'error.main', fontWeight: 600 } }}
/>
</ListItemButton>
</Box>
)}
</Box>
);
};
+1
View File
@@ -9,6 +9,7 @@ export interface AuthUser {
is_superuser: boolean;
theme?: string;
language?: string;
drawer_anchor?: 'left' | 'right';
}
export interface FollowingEntry {
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from 'react';
import api from '../lib/api';
import { useAuth } from './useAuth';
export type DrawerAnchor = 'left' | 'right';
const STORAGE_KEY = 'drawer-anchor';
const readLocal = (): DrawerAnchor => {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === 'right' ? 'right' : 'left';
};
export interface UseDrawerAnchorResult {
anchor: DrawerAnchor;
setAnchor: (anchor: DrawerAnchor) => Promise<void>;
}
export const useDrawerAnchor = (): UseDrawerAnchorResult => {
const { user, refetchUser } = useAuth();
const [anchor, setAnchorState] = useState<DrawerAnchor>(
(user?.drawer_anchor as DrawerAnchor) ?? readLocal(),
);
useEffect(() => {
if (user?.drawer_anchor) {
setAnchorState(user.drawer_anchor);
localStorage.setItem(STORAGE_KEY, user.drawer_anchor);
}
}, [user?.drawer_anchor]);
const setAnchor = useCallback(
async (next: DrawerAnchor) => {
setAnchorState(next);
localStorage.setItem(STORAGE_KEY, next);
if (user) {
try {
await api.patch('users/update_preferences/', { drawer_anchor: next });
await refetchUser();
} catch {
/* fall back to local-only persistence */
}
}
},
[user, refetchUser],
);
return { anchor, setAnchor };
};
+3 -7
View File
@@ -44,10 +44,6 @@ import RatingBadge from '../components/RatingBadge';
import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
interface CommunityFeedProps {
adminMode?: boolean;
}
const SidebarCard: React.FC<{
icon: React.ReactNode;
title: React.ReactNode;
@@ -73,7 +69,7 @@ const SidebarCard: React.FC<{
</GlassCard>
);
const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const CommunityFeed: React.FC = () => {
const { t } = useTranslation();
useTitle(t('community.title'));
const navigate = useNavigate();
@@ -212,7 +208,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
<RatingBadge score={rating.score} />
</Box>
{adminMode && (
{user?.is_superuser && (
<Button
size="small"
variant="outlined"
@@ -317,7 +313,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
<Typography variant="body2">
<RichText text={reply.text} />
</Typography>
{adminMode && (
{user?.is_superuser && (
<Button
size="small"
sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }}
+38
View File
@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom';
import { Box, Button, Typography } from '@mui/material';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { useTranslation } from 'react-i18next';
import { PageShell } from '../components/ui';
const Forbidden = () => {
const { t } = useTranslation();
return (
<PageShell>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
py: { xs: 6, md: 10 },
gap: 2,
}}
>
<LockOutlinedIcon sx={{ fontSize: 72, color: 'text.secondary' }} />
<Typography variant="h3" sx={{ fontWeight: 700 }}>
{t('forbidden.title', { defaultValue: '403 — Forbidden' })}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 480 }}>
{t('forbidden.message', {
defaultValue: 'You do not have permission to view this page.',
})}
</Typography>
<Button variant="contained" component={Link} to="/" sx={{ mt: 1 }}>
{t('forbidden.back', { defaultValue: 'Back to home' })}
</Button>
</Box>
</PageShell>
);
};
export default Forbidden;
+1 -5
View File
@@ -32,10 +32,6 @@ import {
EmptyState,
} from '../components/ui';
interface MainPageProps {
adminMode?: boolean;
}
const TOP_LIMIT = 5;
const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
@@ -81,7 +77,7 @@ const HeroCarouselSkeleton: React.FC = () => (
</GlassCard>
);
const MainPage: React.FC<MainPageProps> = (_props) => {
const MainPage: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const [activeIndex, setActiveIndex] = useState(0);
+2 -6
View File
@@ -47,11 +47,7 @@ import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../com
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface PublicProfileProps {
adminMode?: boolean;
}
const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
const PublicProfile: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const { username } = useParams<{ username: string }>();
@@ -635,7 +631,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
</Typography>
</CardContent>
</Link>
{adminMode && (
{currentUser?.is_superuser && (
<Box sx={{ px: 1, pb: 1 }}>
<Button
size="small"
+29
View File
@@ -24,6 +24,8 @@ import {
ListSubheader,
Divider,
Grid,
FormControlLabel,
Switch,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate } from 'react-router-dom';
@@ -32,6 +34,7 @@ import { useTitle } from '../hooks/useTitle';
import type { CatppuccinTheme } from '../theme';
import { PageShell, GlassCard, FormCard } from '../components/ui';
import { useConfirm } from '../hooks/useConfirm';
import { useDrawerAnchor } from '../hooks/useDrawerAnchor';
interface SettingsProps {
themeName: CatppuccinTheme;
@@ -46,6 +49,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const navigate = useNavigate();
useTitle(t('nav.settings'));
const { confirm } = useConfirm();
const { anchor: drawerAnchor, setAnchor: setDrawerAnchor } = useDrawerAnchor();
const { data: me } = useMe();
const { data: banners = [] } = useBannersList();
const updateAvatarMutation = useUpdateAvatar();
@@ -295,6 +299,31 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
<MenuItem value="de">Deutsch</MenuItem>
</Select>
</FormControl>
<Divider sx={{ my: 2 }} />
<FormControlLabel
control={
<Switch
checked={drawerAnchor === 'right'}
onChange={(e) => setDrawerAnchor(e.target.checked ? 'right' : 'left')}
/>
}
label={
<Box>
<Typography variant="body1">
{t('settings.leftHandMode', { defaultValue: 'Left-hand mode' })}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('settings.leftHandModeHint', {
defaultValue:
'Mobile drawer slides in from the right (better for left-handed thumb reach).',
})}
</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', mt: 1 }}
/>
</GlassCard>
{/* Avatar: NOT a FormCard — file input lives outside any form */}
+12 -152
View File
@@ -11,46 +11,33 @@ import {
Collapse,
CircularProgress,
IconButton,
Avatar,
alpha,
useTheme,
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Navigate } from 'react-router-dom';
import { useTickets } from '../api/queries/useTickets';
import { useMe } from '../api/queries/useMe';
import { useNotificationsQuery } from '../api/queries/useNotifications';
import {
useAddTicketMessage,
useCreateTicket,
useDeleteTicket,
useUpdateTicketStatus,
} from '../api/mutations/useTicketMutations';
import { useAddTicketMessage, useCreateTicket } from '../api/mutations/useTicketMutations';
import { useTitle } from '../hooks/useTitle';
import { formatDate } from '../utils/date';
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
import { useAuth } from '../hooks/useAuth';
type ChipStatusColor = 'error' | 'warning' | 'success' | 'default';
type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary';
const Support: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
useTitle(t('support.title'));
const { user, loadingUser } = useAuth();
const { data: tickets = [], isLoading: loading } = useTickets();
const { data: currentUser = null } = useMe();
const { data: notifications = [] } = useNotificationsQuery(true);
const createTicketMutation = useCreateTicket();
const addMessage = useAddTicketMessage();
const updateStatusMutation = useUpdateTicketStatus();
const deleteTicketMutation = useDeleteTicket();
const [showCreate, setShowCreate] = useState(false);
const [subject, setSubject] = useState('');
@@ -59,14 +46,10 @@ const Support: React.FC = () => {
const [expandedId, setExpandedId] = useState<number | null>(null);
const [readTicketIds, setReadTicketIds] = useState<Set<number>>(new Set());
const { notify } = useToast();
const { confirm } = useConfirm();
const unreadTicketIds = useMemo(() => {
const ids = new Set<number>();
notifications.forEach((n) => {
// Notification carries .reply or .rating in its current shape; tickets
// arrive as ticket_new / ticket_reply with the ticket id encoded in
// .reply (legacy field name re-purposed by the serializer).
const ticketId = (n as unknown as { ticket?: number | null }).ticket;
if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) {
if (!readTicketIds.has(ticketId)) ids.add(ticketId);
@@ -93,6 +76,10 @@ const Support: React.FC = () => {
setSubject('');
setDescription('');
setShowCreate(false);
notify({
message: t('support.ticketCreated', { defaultValue: 'Ticket created' }),
severity: 'success',
});
} catch {
notify({ message: 'Failed to create ticket', severity: 'error' });
}
@@ -109,23 +96,6 @@ const Support: React.FC = () => {
}
};
const handleUpdateStatus = async (ticketId: number, status: string) => {
try {
await updateStatusMutation.mutateAsync({ ticketId, status });
} catch {
notify({ message: 'Failed to update status', severity: 'error' });
}
};
const handleDeleteTicket = async (ticketId: number) => {
if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return;
try {
await deleteTicketMutation.mutateAsync(ticketId);
} catch {
notify({ message: 'Failed to delete ticket', severity: 'error' });
}
};
const getChipColor = (status: string): ChipStatusColor => {
switch (status) {
case 'open':
@@ -139,18 +109,9 @@ const Support: React.FC = () => {
}
};
const getButtonColor = (status: string): ButtonStatusColor => {
switch (status) {
case 'open':
return 'error';
case 'in_progress':
return 'warning';
case 'resolved':
return 'success';
default:
return 'primary';
}
};
if (!loadingUser && user?.is_superuser) {
return <Navigate to="/admin-panel/tickets" replace />;
}
if (loading)
return (
@@ -215,10 +176,7 @@ const Support: React.FC = () => {
</FormCard>
</Collapse>
<SectionHeader
title={currentUser?.is_superuser ? `${t('admin.users')} Tickets` : t('support.myTickets')}
compact
/>
<SectionHeader title={t('support.myTickets')} compact />
{tickets.length === 0 ? (
<EmptyState title={t('support.noTickets')} />
@@ -258,15 +216,6 @@ const Support: React.FC = () => {
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{ticket.subject}
{currentUser?.is_superuser && (
<Typography
component="span"
variant="caption"
sx={{ ml: 1, color: 'primary.main' }}
>
({ticket.username})
</Typography>
)}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(ticket.created_at)}
@@ -288,75 +237,8 @@ const Support: React.FC = () => {
size="small"
sx={{ fontWeight: 'bold' }}
/>
{currentUser?.is_superuser && (
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
handleDeleteTicket(ticket.id);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Stack>
</Box>
{currentUser?.is_superuser && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mt: 1,
p: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.action.active, 0.05),
}}
>
<Avatar
src={ticket.user_avatar || undefined}
sx={{ width: 24, height: 24, fontSize: '0.7rem' }}
>
{ticket.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
fontWeight: 'bold',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{ticket.username} •{' '}
<span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
</Typography>
</Box>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/profile/${ticket.username}`);
}}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
View Profile
</Button>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/admin-panel/user/${ticket.user}`);
}}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
{t('admin.manageUser')}
</Button>
</Box>
)}
</Box>
<Collapse in={expandedId === ticket.id}>
@@ -364,28 +246,6 @@ const Support: React.FC = () => {
<Box
sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}
>
{currentUser?.is_superuser && (
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
{(['open', 'in_progress', 'resolved', 'closed'] as const).map((s) => (
<Button
key={s}
size="small"
variant={ticket.status === s ? 'contained' : 'outlined'}
onClick={() => handleUpdateStatus(ticket.id, s)}
color={getButtonColor(s)}
sx={{
textTransform: 'none',
borderRadius: 4,
fontSize: '0.7rem',
flexShrink: 0,
}}
>
{t(`support.${s}`)}
</Button>
))}
</Stack>
)}
<Typography
variant="body2"
paragraph
+188
View File
@@ -0,0 +1,188 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Box, Button, CardContent, Chip, CircularProgress, Stack, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { EmptyState, GlassCard } from '../../components/ui';
import { useToast } from '../../hooks/useToast';
import { useBannersList } from '../../api/queries/useBanners';
import { useActivateBanner, useToggleBannerEnabled } from '../../api/mutations/useAdminMutations';
import BannerSettingsDialog from '../../components/BannerSettingsDialog';
interface BannerSettingSchema {
key: string;
label: string;
type: 'number' | 'slider' | 'text' | 'boolean';
min?: number;
max?: number;
step?: number;
description?: string;
}
interface Banner {
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
is_enabled: boolean;
settings: Record<string, unknown>;
schema: BannerSettingSchema[];
}
const AdminBanners = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { notify } = useToast();
const { data: banners = [], isLoading } = useBannersList() as unknown as {
data: Banner[];
isLoading: boolean;
};
const activateBannerMutation = useActivateBanner();
const toggleBannerEnabledMutation = useToggleBannerEnabled();
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
const handleActivateBanner = async (id: number) => {
try {
await activateBannerMutation.mutateAsync(id);
notify({ message: 'Banner set as default', severity: 'success' });
} catch (err) {
console.error(err);
notify({ message: 'Failed to activate banner', severity: 'error' });
}
};
const handleToggleEnabled = async (id: number) => {
try {
await toggleBannerEnabledMutation.mutateAsync(id);
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
notify({
message: error.response?.data?.error || 'Failed to toggle visibility',
severity: 'error',
});
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
{t('admin.banners')} ({banners.length})
</Typography>
{banners.length === 0 ? (
<EmptyState
title="No banner models found"
subtitle="No banners registered in the database."
action={
<Button
variant="outlined"
onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })}
>
Retry Loading
</Button>
}
/>
) : (
<Stack spacing={3}>
{banners.map((banner) => (
<GlassCard
key={banner.id}
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
>
<CardContent>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{banner.name} (Slug: {banner.slug})
</Typography>
<Stack direction="row" spacing={1}>
{!banner.is_enabled && (
<Chip
label="Hidden from Users"
color="warning"
size="small"
variant="outlined"
/>
)}
{banner.is_active && (
<Chip label="Global Default" color="success" size="small" />
)}
</Stack>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{banner.description}
</Typography>
<Box sx={{ p: 2, bgcolor: 'action.hover', borderRadius: 2, mb: 2 }}>
<pre style={{ margin: 0, fontSize: '0.8rem' }}>
{JSON.stringify(banner.settings, null, 2)}
</pre>
</Box>
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Button
variant="outlined"
size="small"
onClick={() => {
setSelectedBanner(banner);
setIsSettingsDialogOpen(true);
}}
>
Edit Settings
</Button>
<Button
variant="outlined"
size="small"
color={banner.is_enabled ? 'warning' : 'success'}
onClick={() => handleToggleEnabled(banner.id)}
disabled={banner.is_active}
>
{banner.is_enabled ? 'Disable for Users' : 'Enable for Users'}
</Button>
{!banner.is_active && (
<Button
variant="contained"
size="small"
onClick={() => handleActivateBanner(banner.id)}
>
Set as Default
</Button>
)}
</Stack>
</CardContent>
</GlassCard>
))}
</Stack>
)}
<BannerSettingsDialog
open={isSettingsDialogOpen}
onClose={() => {
setIsSettingsDialogOpen(false);
setSelectedBanner(null);
}}
banner={selectedBanner}
onSave={() => qc.invalidateQueries({ queryKey: ['banners'] })}
/>
</Box>
);
};
export default AdminBanners;
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Box,
Button,
CircularProgress,
FormControlLabel,
FormGroup,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { useTranslation } from 'react-i18next';
import api from '../../lib/api';
import { GlassPaper } from '../../components/ui';
import { useToast } from '../../hooks/useToast';
import { useAdminConfig } from '../../api/queries/useAdminQueries';
interface SystemConfig {
site_name: string;
maintenance_mode: boolean;
allow_new_signups: boolean;
require_email_verification: boolean;
}
const blankConfig: SystemConfig = {
site_name: '',
maintenance_mode: false,
allow_new_signups: true,
require_email_verification: false,
};
const AdminConfig = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { notify } = useToast();
const { data: config = null, isLoading } = useAdminConfig() as unknown as {
data: SystemConfig | null | undefined;
isLoading: boolean;
};
const [draft, setDraft] = useState<SystemConfig>(blankConfig);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (config) setDraft(config);
}, [config]);
const dirty = config
? (Object.keys(blankConfig) as Array<keyof SystemConfig>).some((k) => draft[k] !== config[k])
: false;
const handleSave = async () => {
setSaving(true);
try {
await api.patch('admin-custom/config/', draft);
qc.invalidateQueries({ queryKey: ['adminConfig'] });
notify({
message: t('admin.configSaved', { defaultValue: 'Configuration saved' }),
severity: 'success',
});
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
notify({
message: error.response?.data?.error || 'Failed to save configuration',
severity: 'error',
});
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (config) setDraft(config);
};
if (isLoading || !config) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 1 }}>
{t('admin.sections.config', { defaultValue: 'System Configuration' })}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Live values applied without restart. Form posts the full config on save.
</Typography>
<GlassPaper intensity="subtle" sx={{ p: 3, maxWidth: 720 }}>
<Stack spacing={3} component="form" onSubmit={(e) => e.preventDefault()}>
<TextField
label="Site Name"
value={draft.site_name}
onChange={(e) => setDraft({ ...draft, site_name: e.target.value })}
fullWidth
helperText="Displayed in the browser tab and various headers."
/>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={draft.maintenance_mode}
onChange={(e) => setDraft({ ...draft, maintenance_mode: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body1">Maintenance Mode</Typography>
<Typography variant="caption" color="text.secondary">
Block non-admin users from interacting with the site.
</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={draft.allow_new_signups}
onChange={(e) => setDraft({ ...draft, allow_new_signups: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body1">Allow New Signups</Typography>
<Typography variant="caption" color="text.secondary">
When off, the registration endpoint rejects new accounts.
</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={draft.require_email_verification}
onChange={(e) =>
setDraft({ ...draft, require_email_verification: e.target.checked })
}
/>
}
label={
<Box>
<Typography variant="body1">Require Email Verification</Typography>
<Typography variant="caption" color="text.secondary">
New accounts must confirm their email before logging in.
</Typography>
</Box>
}
/>
</FormGroup>
<Stack direction="row" spacing={2}>
<Button
type="submit"
variant="contained"
startIcon={<SaveIcon />}
disabled={!dirty || saving}
onClick={handleSave}
>
{saving ? 'Saving…' : 'Save changes'}
</Button>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={handleReset}
disabled={!dirty || saving}
>
Discard
</Button>
</Stack>
</Stack>
</GlassPaper>
</Box>
);
};
export default AdminConfig;
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Alert,
Box,
Button,
CardContent,
Chip,
CircularProgress,
FormControl,
Grid,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { GlassCard } from '../../components/ui';
import { useToast } from '../../hooks/useToast';
import { formatDate } from '../../utils/date';
import { useAdminJobs } from '../../api/queries/useAdminQueries';
import { useTriggerJob, useUpdateJobSchedule } from '../../api/mutations/useAdminMutations';
interface Job {
id: number;
name: string;
name_display: string;
status: 'pending' | 'running' | 'completed' | 'failed';
last_run: string | null;
next_run: string | null;
interval_hours: number;
last_output: string;
error_message: string;
}
const AdminJobs = () => {
const qc = useQueryClient();
const { notify } = useToast();
const [search, setSearch] = useState('');
const { data: jobs = [], isLoading } = useAdminJobs() as unknown as {
data: Job[];
isLoading: boolean;
};
const triggerJobMutation = useTriggerJob();
const updateJobScheduleMutation = useUpdateJobSchedule();
useEffect(() => {
const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending');
if (anyRunning) {
const interval = setInterval(() => {
qc.invalidateQueries({ queryKey: ['adminJobs'] });
}, 3000);
return () => clearInterval(interval);
}
}, [jobs, qc]);
const handleTriggerJob = async (id: number) => {
try {
await triggerJobMutation.mutateAsync(id);
notify({ message: 'Job queued', severity: 'success' });
} catch {
notify({ message: 'Failed to trigger job', severity: 'error' });
}
};
const handleUpdateJobSchedule = async (id: number, interval: number) => {
try {
await updateJobScheduleMutation.mutateAsync({ id, data: { interval_hours: interval } });
} catch {
notify({ message: 'Failed to update schedule', severity: 'error' });
}
};
const filtered = jobs.filter(
(j) =>
j.name_display.toLowerCase().includes(search.toLowerCase()) ||
j.name.toLowerCase().includes(search.toLowerCase()),
);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', md: 'center' }}
sx={{ mb: 3 }}
spacing={2}
>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Background Worker Jobs ({filtered.length})
</Typography>
<TextField
size="small"
placeholder="Filter jobs…"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ width: { xs: '100%', md: 300 } }}
/>
</Stack>
<Stack spacing={3}>
{filtered.map((job) => (
<GlassCard
key={job.id}
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
>
<CardContent>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{job.name_display}
</Typography>
<Chip
label={job.status === 'pending' ? 'QUEUED' : job.status.toUpperCase()}
size="small"
color={
job.status === 'completed'
? 'success'
: job.status === 'running'
? 'info'
: job.status === 'failed'
? 'error'
: 'default'
}
variant={job.status === 'running' ? 'filled' : 'outlined'}
/>
</Box>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">
Last Run
</Typography>
<Typography variant="body2">
{job.last_run ? formatDate(job.last_run) : 'Never'}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">
Next Run
</Typography>
<Typography variant="body2">
{job.next_run
? formatDate(job.next_run)
: job.interval_hours > 0
? 'Queued…'
: 'Disabled'}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mb: 0.5 }}
>
Schedule
</Typography>
<FormControl size="small" fullWidth>
<Select
value={job.interval_hours}
onChange={(e) => handleUpdateJobSchedule(job.id, Number(e.target.value))}
sx={{ height: 32, fontSize: '0.875rem' }}
>
<MenuItem value={0}>Disabled</MenuItem>
<MenuItem value={1}>Hourly</MenuItem>
<MenuItem value={6}>Every 6 Hours</MenuItem>
<MenuItem value={12}>Every 12 Hours</MenuItem>
<MenuItem value={24}>Daily</MenuItem>
<MenuItem value={168}>Weekly</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
{job.last_output && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
Last Run Output
</Typography>
<Box
sx={{
p: 1.5,
bgcolor: '#1e1e1e',
color: '#d4d4d4',
borderRadius: 2,
maxHeight: 150,
overflowY: 'auto',
border: '1px solid #333',
}}
>
<pre
style={{
margin: 0,
fontSize: '0.75rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
}}
>
{job.last_output}
</pre>
</Box>
</Box>
)}
{job.error_message && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="error" display="block" gutterBottom>
Last Error
</Typography>
<Alert severity="error" sx={{ py: 0 }}>
{job.error_message}
</Alert>
</Box>
)}
<Button
variant="contained"
startIcon={<PlayArrowIcon />}
disabled={job.status === 'running'}
onClick={() => handleTriggerJob(job.id)}
>
Trigger Now
</Button>
</CardContent>
</GlassCard>
))}
</Stack>
</Box>
);
};
export default AdminJobs;
+116
View File
@@ -0,0 +1,116 @@
import { Link as RouterLink, Outlet, useLocation, useParams } from 'react-router-dom';
import {
Box,
Breadcrumbs,
Link as MuiLink,
Tab,
Tabs,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import PeopleIcon from '@mui/icons-material/People';
import WallpaperIcon from '@mui/icons-material/Wallpaper';
import TerminalIcon from '@mui/icons-material/Terminal';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import TuneIcon from '@mui/icons-material/Tune';
import { useTranslation } from 'react-i18next';
import { PageShell } from '../../components/ui';
import { useTitle } from '../../hooks/useTitle';
const SECTIONS = [
{ value: 'overview', icon: <SettingsIcon /> },
{ value: 'users', icon: <PeopleIcon /> },
{ value: 'banners', icon: <WallpaperIcon /> },
{ value: 'jobs', icon: <TerminalIcon /> },
{ value: 'tickets', icon: <HelpOutlineIcon /> },
{ value: 'config', icon: <TuneIcon /> },
] as const;
const AdminLayout = () => {
const { t } = useTranslation();
const location = useLocation();
const params = useParams();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useTitle(t('admin.title'));
const segments = location.pathname.split('/').filter(Boolean);
const sectionSlug = segments[1] ?? 'overview';
const detailSegment = segments[2];
const sectionKnown = SECTIONS.some((s) => s.value === sectionSlug);
const tabValue = sectionKnown ? sectionSlug : 'overview';
const sectionLabel = (slug: string) =>
t(`admin.sections.${slug}`, {
defaultValue: slug.charAt(0).toUpperCase() + slug.slice(1),
});
const detailLabel = () => {
if (!detailSegment) return null;
if (sectionSlug === 'users' && params.id) return `User #${params.id}`;
if (sectionSlug === 'ratings' || segments[1] === 'rating')
return `Rating #${params.id ?? detailSegment}`;
if (sectionSlug === 'replies' || segments[1] === 'reply')
return `Reply #${params.id ?? detailSegment}`;
return decodeURIComponent(detailSegment);
};
return (
<PageShell>
<Breadcrumbs sx={{ mb: 2 }} separator="›">
<MuiLink component={RouterLink} to="/" underline="hover" color="inherit">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<HomeOutlinedIcon fontSize="small" />
{t('common.home', { defaultValue: 'Home' })}
</Box>
</MuiLink>
<MuiLink component={RouterLink} to="/admin-panel" underline="hover" color="inherit">
{t('admin.title')}
</MuiLink>
{sectionKnown && (
<MuiLink
component={RouterLink}
to={`/admin-panel/${sectionSlug}`}
underline="hover"
color={detailSegment ? 'inherit' : 'text.primary'}
>
{sectionLabel(sectionSlug)}
</MuiLink>
)}
{detailLabel() && <Typography color="text.primary">{detailLabel()}</Typography>}
</Breadcrumbs>
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 'bold', mb: 3 }}>
{t('admin.title')}
</Typography>
<Tabs
value={tabValue}
variant={isMobile ? 'scrollable' : 'standard'}
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }}
>
{SECTIONS.map((s) => (
<Tab
key={s.value}
value={s.value}
label={sectionLabel(s.value)}
icon={s.icon}
iconPosition="start"
component={RouterLink}
to={`/admin-panel/${s.value}`}
/>
))}
</Tabs>
<Outlet />
</PageShell>
);
};
export default AdminLayout;
+197
View File
@@ -0,0 +1,197 @@
import {
alpha,
Box,
Button,
CardContent,
CircularProgress,
Divider,
Grid,
Stack,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import EmailIcon from '@mui/icons-material/Email';
import InfoIcon from '@mui/icons-material/Info';
import { useTranslation } from 'react-i18next';
import { GlassCard, GlassPaper } from '../../components/ui';
import { useToast } from '../../hooks/useToast';
import { useAdminStats } from '../../api/queries/useAdminQueries';
import { useSendTestEmail } from '../../api/mutations/useAdminMutations';
interface Stats {
total_users: number;
total_ratings: number;
total_replies: number;
open_tickets: number;
email_config: {
host: string;
port: number;
use_tls: boolean;
use_ssl: boolean;
skip_verify: boolean;
};
server_info: {
debug: boolean;
allowed_hosts: string[];
frontend_url: string;
media_root: string;
static_root: string;
};
}
const AdminOverview = () => {
const { t } = useTranslation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { notify } = useToast();
const { data: stats = null, isLoading } = useAdminStats() as unknown as {
data: Stats | null | undefined;
isLoading: boolean;
};
const sendTestEmailMutation = useSendTestEmail();
const handleSendTestEmail = async () => {
try {
await sendTestEmailMutation.mutateAsync();
notify({ message: t('admin.testEmailSuccess'), severity: 'success' });
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
notify({
message: `Error: ${error.response?.data?.error || 'Failed to send'}`,
severity: 'error',
});
}
};
if (isLoading || !stats) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
<Grid size={{ xs: 6, md: 3 }}>
<GlassCard
intensity="subtle"
sx={{ bgcolor: (th) => alpha(th.palette.primary.main, 0.1) }}
>
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
{t('admin.totalUsers')}
</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 'bold' }}>
{stats.total_users}
</Typography>
</CardContent>
</GlassCard>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<GlassCard intensity="subtle" sx={{ bgcolor: (th) => alpha(th.palette.error.main, 0.1) }}>
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
{t('admin.openTickets')}
</Typography>
<Typography
variant={isMobile ? 'h5' : 'h4'}
sx={{ fontWeight: 'bold', color: 'error.main' }}
>
{stats.open_tickets}
</Typography>
</CardContent>
</GlassCard>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<GlassCard intensity="subtle">
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
<Stack
direction={isMobile ? 'column' : 'row'}
justifyContent="space-between"
alignItems={isMobile ? 'flex-start' : 'center'}
spacing={2}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{t('admin.config')}: SMTP
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{stats.email_config.host}:{stats.email_config.port} • TLS:{' '}
{String(stats.email_config.use_tls)} • Insecure:{' '}
{String(stats.email_config.skip_verify)}
</Typography>
</Box>
<Button
variant="outlined"
fullWidth={isMobile}
startIcon={<EmailIcon />}
onClick={handleSendTestEmail}
size="small"
>
{t('admin.sendTestEmail')}
</Button>
</Stack>
</CardContent>
</GlassCard>
</Grid>
</Grid>
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
Environment Info (.env)
</Typography>
<GlassPaper intensity="subtle" sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
>
API Status
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
DEBUG={String(stats.server_info.debug)}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
>
Frontend URL
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{stats.server_info.frontend_url}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
>
Allowed Hosts
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{stats.server_info.allowed_hosts.join(', ')}
</Typography>
</Box>
<Divider />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<InfoIcon fontSize="small" color="primary" />
<Typography variant="caption" color="text.secondary">
These values are read from your server environment or .env file. To change them, you
must update the file and restart the backend container.
</Typography>
</Box>
</Stack>
</GlassPaper>
</Box>
);
};
export default AdminOverview;
+410
View File
@@ -0,0 +1,410 @@
import React, { useState, useMemo } from 'react';
import {
alpha,
Avatar,
Box,
Button,
CardContent,
Chip,
CircularProgress,
Collapse,
Divider,
IconButton,
Stack,
TextField,
Typography,
useTheme,
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useTickets } from '../../api/queries/useTickets';
import { useNotificationsQuery } from '../../api/queries/useNotifications';
import {
useAddTicketMessage,
useDeleteTicket,
useUpdateTicketStatus,
} from '../../api/mutations/useTicketMutations';
import { formatDate } from '../../utils/date';
import { GlassCard, EmptyState } from '../../components/ui';
import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm';
type ChipStatusColor = 'error' | 'warning' | 'success' | 'default';
type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary';
const getChipColor = (status: string): ChipStatusColor => {
switch (status) {
case 'open':
return 'error';
case 'in_progress':
return 'warning';
case 'resolved':
return 'success';
default:
return 'default';
}
};
const getButtonColor = (status: string): ButtonStatusColor => {
switch (status) {
case 'open':
return 'error';
case 'in_progress':
return 'warning';
case 'resolved':
return 'success';
default:
return 'primary';
}
};
const AdminTickets: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { data: tickets = [], isLoading } = useTickets();
const { data: notifications = [] } = useNotificationsQuery(true);
const addMessage = useAddTicketMessage();
const updateStatusMutation = useUpdateTicketStatus();
const deleteTicketMutation = useDeleteTicket();
const { notify } = useToast();
const { confirm } = useConfirm();
const [replyText, setReplyText] = useState<Record<number, string>>({});
const [expandedId, setExpandedId] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [readTicketIds, setReadTicketIds] = useState<Set<number>>(new Set());
const unreadTicketIds = useMemo(() => {
const ids = new Set<number>();
notifications.forEach((n) => {
const ticketId = (n as unknown as { ticket?: number | null }).ticket;
if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) {
if (!readTicketIds.has(ticketId)) ids.add(ticketId);
}
});
return ids;
}, [notifications, readTicketIds]);
const filtered = useMemo(() => {
if (statusFilter === 'all') return tickets;
return tickets.filter((tk) => tk.status === statusFilter);
}, [tickets, statusFilter]);
const handleExpand = (ticketId: number) => {
if (expandedId === ticketId) {
setExpandedId(null);
return;
}
setExpandedId(ticketId);
if (unreadTicketIds.has(ticketId)) {
setReadTicketIds((prev) => new Set(prev).add(ticketId));
}
};
const handleReply = async (ticketId: number) => {
const text = replyText[ticketId];
if (!text) return;
try {
await addMessage.mutateAsync({ ticketId, text });
setReplyText({ ...replyText, [ticketId]: '' });
} catch {
notify({ message: 'Failed to send message', severity: 'error' });
}
};
const handleUpdateStatus = async (ticketId: number, status: string) => {
try {
await updateStatusMutation.mutateAsync({ ticketId, status });
notify({ message: `Status set to ${t(`support.${status}`)}`, severity: 'success' });
} catch {
notify({ message: 'Failed to update status', severity: 'error' });
}
};
const handleDeleteTicket = async (ticketId: number) => {
if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return;
try {
await deleteTicketMutation.mutateAsync(ticketId);
notify({ message: 'Ticket deleted', severity: 'success' });
} catch {
notify({ message: 'Failed to delete ticket', severity: 'error' });
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', md: 'center' }}
spacing={2}
sx={{ mb: 3 }}
>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
{t('support.title')} ({filtered.length})
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{(['all', 'open', 'in_progress', 'resolved', 'closed'] as const).map((s) => (
<Chip
key={s}
label={s === 'all' ? t('common.all', { defaultValue: 'All' }) : t(`support.${s}`)}
clickable
variant={statusFilter === s ? 'filled' : 'outlined'}
color={s === 'all' ? 'default' : getChipColor(s)}
onClick={() => setStatusFilter(s)}
/>
))}
</Stack>
</Stack>
{filtered.length === 0 ? (
<EmptyState title={t('support.noTickets')} />
) : (
<Stack spacing={2}>
{filtered.map((ticket) => (
<GlassCard
key={ticket.id}
sx={{
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : undefined,
}}
>
<CardContent sx={{ p: 0 }}>
<Box
onClick={() => handleExpand(ticket.id)}
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{unreadTicketIds.has(ticket.id) && (
<Box
sx={{
width: 10,
height: 10,
bgcolor: 'primary.main',
borderRadius: '50%',
}}
/>
)}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{ticket.subject}
<Typography
component="span"
variant="caption"
sx={{ ml: 1, color: 'primary.main' }}
>
({ticket.username})
</Typography>
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(ticket.created_at)}
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
{unreadTicketIds.has(ticket.id) && (
<Chip
label={t('support.unread')}
color="primary"
size="small"
sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }}
/>
)}
<Chip
label={t(`support.${ticket.status}`)}
color={getChipColor(ticket.status)}
size="small"
sx={{ fontWeight: 'bold' }}
/>
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
handleDeleteTicket(ticket.id);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Stack>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mt: 1,
p: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.action.active, 0.05),
}}
>
<Avatar
src={ticket.user_avatar || undefined}
sx={{ width: 24, height: 24, fontSize: '0.7rem' }}
>
{ticket.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
fontWeight: 'bold',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{ticket.username} •{' '}
<span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
</Typography>
</Box>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/profile/${ticket.username}`);
}}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
View Profile
</Button>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/admin-panel/users/${ticket.user}`);
}}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
{t('admin.manageUser')}
</Button>
</Box>
</Box>
<Collapse in={expandedId === ticket.id}>
<Divider />
<Box sx={{ p: 2, bgcolor: (th) => alpha(th.palette.background.default, 0.5) }}>
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
{(['open', 'in_progress', 'resolved', 'closed'] as const).map((s) => (
<Button
key={s}
size="small"
variant={ticket.status === s ? 'contained' : 'outlined'}
onClick={() => handleUpdateStatus(ticket.id, s)}
color={getButtonColor(s)}
sx={{
textTransform: 'none',
borderRadius: 4,
fontSize: '0.7rem',
flexShrink: 0,
}}
>
{t(`support.${s}`)}
</Button>
))}
</Stack>
<Typography
variant="body2"
paragraph
sx={{
whiteSpace: 'pre-wrap',
mb: 3,
bgcolor: 'background.paper',
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
}}
>
{ticket.description}
</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{ticket.messages.map((msg) => (
<Box
key={msg.id}
sx={{
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
maxWidth: '85%',
p: 1.5,
borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
color: msg.is_admin ? 'white' : 'inherit',
boxShadow: msg.is_admin ? 2 : 0,
}}
>
<Typography
variant="caption"
sx={{ fontWeight: 'bold', display: 'block' }}
>
{msg.username} {msg.is_admin && '(Support)'}
</Typography>
<Typography variant="body2" sx={{ my: 0.5 }}>
{msg.text}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}
>
{formatDate(msg.created_at)}
</Typography>
</Box>
))}
</Stack>
{ticket.status !== 'closed' && (
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
placeholder={t('community.writeReply')}
value={replyText[ticket.id] || ''}
onChange={(e) =>
setReplyText({ ...replyText, [ticket.id]: e.target.value })
}
onKeyDown={(e) =>
e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)
}
/>
<IconButton
color="primary"
onClick={() => handleReply(ticket.id)}
disabled={!replyText[ticket.id]}
>
<SendIcon />
</IconButton>
</Stack>
)}
</Box>
</Collapse>
</CardContent>
</GlassCard>
))}
</Stack>
)}
</Box>
);
};
export default AdminTickets;
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
CardContent,
Chip,
CircularProgress,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import { useTranslation } from 'react-i18next';
import { GlassCard, GlassPaper } from '../../components/ui';
import { formatDate } from '../../utils/date';
import { useAdminUsers } from '../../api/queries/useAdminQueries';
interface AdminUser {
id: number;
username: string;
email: string;
is_active: boolean;
is_superuser: boolean;
date_joined: string;
last_login: string;
ips: string[];
}
const AdminUsers = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [search, setSearch] = useState('');
const { data: users = [], isLoading } = useAdminUsers() as unknown as {
data: AdminUser[];
isLoading: boolean;
};
const filtered = users.filter(
(u) =>
u.username.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase()),
);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Stack
direction={isMobile ? 'column' : 'row'}
justifyContent="space-between"
alignItems={isMobile ? 'flex-start' : 'center'}
sx={{ mb: 2 }}
spacing={2}
>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
{t('admin.users')} ({filtered.length})
</Typography>
<TextField
size="small"
placeholder={t('common.search', { defaultValue: 'Search…' })}
fullWidth={isMobile}
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ width: isMobile ? '100%' : 300 }}
/>
</Stack>
{isMobile ? (
<Stack spacing={2}>
{filtered.map((user) => (
<GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{user.username}
</Typography>
<IconButton
size="small"
onClick={() => navigate(`/admin-panel/users/${user.id}`)}
>
<EditIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="caption" color="text.secondary" display="block">
{user.email}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Chip
label={user.is_active ? 'Active' : 'Inactive'}
color={user.is_active ? 'success' : 'default'}
size="small"
sx={{ height: 20, fontSize: '0.65rem' }}
/>
{user.is_superuser && (
<Chip
label="Admin"
color="secondary"
size="small"
sx={{ height: 20, fontSize: '0.65rem' }}
/>
)}
</Stack>
</CardContent>
</GlassCard>
))}
</Stack>
) : (
<TableContainer component={GlassPaper} sx={{ borderRadius: 3 }}>
<Table>
<TableHead sx={{ bgcolor: 'action.hover' }}>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="right">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map((user) => (
<TableRow key={user.id} hover>
<TableCell>{user.id}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{user.username}
</Typography>
{user.is_superuser && (
<Chip
label="Superuser"
size="small"
color="secondary"
sx={{ height: 16, fontSize: '0.6rem' }}
/>
)}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Chip
label={user.is_active ? 'Active' : 'Inactive'}
color={user.is_active ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell>{formatDate(user.date_joined)}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap">
{user.ips.slice(0, 3).map((ip) => (
<Chip
key={ip}
label={ip}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
))}
{user.ips.length > 3 && (
<Typography variant="caption">+{user.ips.length - 3}</Typography>
)}
</Stack>
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => navigate(`/admin-panel/users/${user.id}`)}
>
<EditIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
};
export default AdminUsers;
+5 -8
View File
@@ -56,11 +56,7 @@ import RatingForm from './components/RatingForm';
import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm';
interface FlavorDetailProps {
adminMode?: boolean;
}
const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const FlavorDetail: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const { id } = useParams<{ id: string }>();
@@ -70,6 +66,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const { data: flavor, isLoading: loading } = useFlavorDetail(id);
const { data: meData } = useCurrentUserLite();
const currentUser = meData?.username ?? null;
const isSuperuser = !!meData?.is_superuser;
const updateFlavor = useUpdateFlavor();
const deleteRatingMutation = useDeleteRating();
const updateRating = useUpdateRating();
@@ -257,7 +254,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
/>
<GlassCard sx={{ mt: { xs: 0, lg: 2 } }}>
<CardContent sx={{ p: 3 }}>
{adminMode && (
{isSuperuser && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{!isAdminEditing ? (
<Button
@@ -522,7 +519,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<RatingBadge score={rating.score} />
{adminMode && (
{isSuperuser && (
<Button
size="small"
variant="outlined"
@@ -686,7 +683,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
</Button>
</Box>
)}
{adminMode && (
{isSuperuser && (
<Button
size="small"
variant="text"