mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
updated admin panel + side bar
This commit is contained in:
1 parent
22fe0674dd
commit
556bae3556
23 files changed
+2185
-605
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-27 01:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0032_flavor_local_image_paths_flavor_main_image_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='drawer_anchor',
|
||||
field=models.CharField(choices=[('left', 'Left'), ('right', 'Right')], default='left', max_length=5),
|
||||
),
|
||||
]
|
||||
@@ -26,8 +26,15 @@ class User(AbstractUser):
|
||||
("en", "English"),
|
||||
("de", "Deutsch"),
|
||||
]
|
||||
DRAWER_ANCHOR_CHOICES = [
|
||||
("left", "Left"),
|
||||
("right", "Right"),
|
||||
]
|
||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
|
||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en")
|
||||
drawer_anchor = models.CharField(
|
||||
max_length=5, choices=DRAWER_ANCHOR_CHOICES, default="left"
|
||||
)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
|
||||
@@ -24,6 +24,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"email",
|
||||
"theme",
|
||||
"language",
|
||||
"drawer_anchor",
|
||||
"avatar",
|
||||
"following_count",
|
||||
"followers_count",
|
||||
|
||||
@@ -271,6 +271,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
user = current_user(request)
|
||||
theme = request.data.get("theme")
|
||||
language = request.data.get("language")
|
||||
drawer_anchor = request.data.get("drawer_anchor")
|
||||
banner_id = request.data.get("selected_banner")
|
||||
|
||||
updated = False
|
||||
@@ -280,6 +281,9 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
if language in dict(User.LANGUAGE_CHOICES):
|
||||
user.language = language
|
||||
updated = True
|
||||
if drawer_anchor in dict(User.DRAWER_ANCHOR_CHOICES):
|
||||
user.drawer_anchor = drawer_anchor
|
||||
updated = True
|
||||
|
||||
if banner_id is not None:
|
||||
if banner_id == "":
|
||||
@@ -303,6 +307,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
"status": "preferences updated",
|
||||
"theme": user.theme,
|
||||
"language": user.language,
|
||||
"drawer_anchor": user.drawer_anchor,
|
||||
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
|
||||
}
|
||||
)
|
||||
|
||||
+101
-428
@@ -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,25 +292,51 @@ 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={{ 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',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -697,11 +346,11 @@ const App: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage adminMode={adminMode} />} />
|
||||
<Route path="/" element={<MainPage />} />
|
||||
<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="/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 />} />
|
||||
@@ -711,10 +360,31 @@ const App: React.FC = () => {
|
||||
/>
|
||||
<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="/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="*"
|
||||
@@ -731,6 +401,9 @@ const App: React.FC = () => {
|
||||
</Suspense>
|
||||
</Box>
|
||||
<Footer />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export interface AuthUser {
|
||||
is_superuser: boolean;
|
||||
theme?: string;
|
||||
language?: string;
|
||||
drawer_anchor?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface FollowingEntry {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
+11
-151
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user