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
+2217
-637
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"),
|
("en", "English"),
|
||||||
("de", "Deutsch"),
|
("de", "Deutsch"),
|
||||||
]
|
]
|
||||||
|
DRAWER_ANCHOR_CHOICES = [
|
||||||
|
("left", "Left"),
|
||||||
|
("right", "Right"),
|
||||||
|
]
|
||||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
|
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
|
||||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en")
|
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)
|
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
|
||||||
pending_email = models.EmailField(max_length=254, 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)
|
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"email",
|
"email",
|
||||||
"theme",
|
"theme",
|
||||||
"language",
|
"language",
|
||||||
|
"drawer_anchor",
|
||||||
"avatar",
|
"avatar",
|
||||||
"following_count",
|
"following_count",
|
||||||
"followers_count",
|
"followers_count",
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
user = current_user(request)
|
user = current_user(request)
|
||||||
theme = request.data.get("theme")
|
theme = request.data.get("theme")
|
||||||
language = request.data.get("language")
|
language = request.data.get("language")
|
||||||
|
drawer_anchor = request.data.get("drawer_anchor")
|
||||||
banner_id = request.data.get("selected_banner")
|
banner_id = request.data.get("selected_banner")
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
@@ -280,6 +281,9 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
if language in dict(User.LANGUAGE_CHOICES):
|
if language in dict(User.LANGUAGE_CHOICES):
|
||||||
user.language = language
|
user.language = language
|
||||||
updated = True
|
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 is not None:
|
||||||
if banner_id == "":
|
if banner_id == "":
|
||||||
@@ -303,6 +307,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
"status": "preferences updated",
|
"status": "preferences updated",
|
||||||
"theme": user.theme,
|
"theme": user.theme,
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
|
"drawer_anchor": user.drawer_anchor,
|
||||||
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
|
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+132
-459
@@ -1,5 +1,5 @@
|
|||||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
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 {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
@@ -9,15 +9,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
SwipeableDrawer,
|
SwipeableDrawer,
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Collapse,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
ListItemAvatar,
|
|
||||||
Badge,
|
Badge,
|
||||||
alpha,
|
alpha,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
@@ -26,21 +18,7 @@ import {
|
|||||||
import { GlassAppBar } from './components/ui';
|
import { GlassAppBar } from './components/ui';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
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 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 { useTranslation } from 'react-i18next';
|
||||||
import api from './lib/api';
|
import api from './lib/api';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
@@ -50,6 +28,9 @@ import { GlobalSearch } from './app/GlobalSearch';
|
|||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useNotifications } from './hooks/useNotifications';
|
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 { Notification } from './contexts/NotificationContext';
|
||||||
import type { CatppuccinTheme } from './theme';
|
import type { CatppuccinTheme } from './theme';
|
||||||
|
|
||||||
@@ -65,7 +46,14 @@ const Login = lazy(() => import('./pages/Login'));
|
|||||||
const Settings = lazy(() => import('./pages/Settings'));
|
const Settings = lazy(() => import('./pages/Settings'));
|
||||||
const Support = lazy(() => import('./pages/Support'));
|
const Support = lazy(() => import('./pages/Support'));
|
||||||
const VerifyEmail = lazy(() => import('./pages/VerifyEmail'));
|
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 AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
||||||
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
||||||
@@ -73,20 +61,16 @@ const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const muiTheme = useMuiTheme();
|
const muiTheme = useMuiTheme();
|
||||||
const { user, following, loadingUser, logout } = useAuth();
|
const { user, loadingUser, logout } = useAuth();
|
||||||
const { themeName, handleThemeChange } = useTheme();
|
const { themeName, handleThemeChange } = useTheme();
|
||||||
const { notifications, markAllRead, markRead } = useNotifications();
|
const { notifications, markAllRead, markRead } = useNotifications();
|
||||||
|
const { anchor: mobileAnchor } = useDrawerAnchor();
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [notifAnchorEl, setNotifAnchorEl] = 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 [categories, setCategories] = useState<{ name: string; slug: string }[]>([]);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
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'));
|
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,12 +88,6 @@ const App: React.FC = () => {
|
|||||||
document.body.style.color = muiTheme.palette.text.primary;
|
document.body.style.color = muiTheme.palette.text.primary;
|
||||||
}, [muiTheme]);
|
}, [muiTheme]);
|
||||||
|
|
||||||
const toggleAdminMode = () => {
|
|
||||||
const newVal = !adminMode;
|
|
||||||
setAdminMode(newVal);
|
|
||||||
localStorage.setItem('admin-mode', String(newVal));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onThemeChange = async (name: CatppuccinTheme) => {
|
const onThemeChange = async (name: CatppuccinTheme) => {
|
||||||
await handleThemeChange(name, !!user);
|
await handleThemeChange(name, !!user);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
@@ -119,7 +97,7 @@ const App: React.FC = () => {
|
|||||||
if (!notif.is_read) await markRead(notif.id);
|
if (!notif.is_read) await markRead(notif.id);
|
||||||
setNotifAnchorEl(null);
|
setNotifAnchorEl(null);
|
||||||
if (notif.notification_type.startsWith('ticket')) {
|
if (notif.notification_type.startsWith('ticket')) {
|
||||||
navigate('/support');
|
navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support');
|
||||||
} else if (notif.notification_type === 'profile_comment') {
|
} else if (notif.notification_type === 'profile_comment') {
|
||||||
navigate(`/profile/${user?.username}`);
|
navigate(`/profile/${user?.username}`);
|
||||||
} else if (notif.notification_type === 'follow') {
|
} else if (notif.notification_type === 'follow') {
|
||||||
@@ -129,303 +107,15 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDrawer = () => setDrawerOpen(false);
|
const hamburger = (
|
||||||
const isActive = (path: string) =>
|
<IconButton
|
||||||
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
|
color="inherit"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
const drawer = (
|
aria-label="open navigation"
|
||||||
<Box
|
sx={{ mx: 0.5 }}
|
||||||
sx={{ width: 300, height: '100%', display: 'flex', flexDirection: 'column' }}
|
|
||||||
role="presentation"
|
|
||||||
>
|
>
|
||||||
{user ? (
|
<MenuIcon />
|
||||||
<Box
|
</IconButton>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationsMenu = (
|
const notificationsMenu = (
|
||||||
@@ -527,13 +217,14 @@ const App: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GlassAppBar>
|
<GlassAppBar>
|
||||||
|
{isMobile && mobileAnchor === 'left' && hamburger}
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
component="div"
|
component="div"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
mr: { xs: 0, sm: 2 },
|
mr: { xs: 0, sm: 2 },
|
||||||
display: { xs: 'none', sm: 'block' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
|
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||||
@@ -546,50 +237,6 @@ const App: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</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 />
|
<GlobalSearch />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
@@ -599,7 +246,11 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 1 }}>
|
<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
|
<Avatar
|
||||||
src={user.avatar || undefined}
|
src={user.avatar || undefined}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -620,41 +271,13 @@ const App: React.FC = () => {
|
|||||||
sx={{ mt: 1 }}
|
sx={{ mt: 1 }}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component={Link}
|
onClick={() => {
|
||||||
to={`/profile/${user.username}`}
|
setAnchorEl(null);
|
||||||
onClick={() => setAnchorEl(null)}
|
logout();
|
||||||
|
}}
|
||||||
|
sx={{ color: 'error.main', gap: 1.5 }}
|
||||||
>
|
>
|
||||||
{t('nav.profile')}
|
<LogoutOutlinedIcon fontSize="small" />
|
||||||
</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' }}>
|
|
||||||
{t('nav.logout')}
|
{t('nav.logout')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -669,68 +292,118 @@ const App: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && mobileAnchor === 'right' && hamburger}
|
||||||
<IconButton color="inherit" onClick={() => setDrawerOpen(true)} sx={{ ml: 1 }}>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</GlassAppBar>
|
</GlassAppBar>
|
||||||
|
|
||||||
<SwipeableDrawer
|
<SwipeableDrawer
|
||||||
anchor="right"
|
anchor={mobileAnchor}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onOpen={() => setDrawerOpen(true)}
|
onOpen={() => setDrawerOpen(true)}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
disableBackdropTransition
|
disableBackdropTransition
|
||||||
disableDiscovery
|
disableDiscovery
|
||||||
>
|
>
|
||||||
{drawer}
|
<NavSidebar categories={categories} onNavigate={() => setDrawerOpen(false)} />
|
||||||
</SwipeableDrawer>
|
</SwipeableDrawer>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
|
||||||
<Suspense
|
{!isMobile && (
|
||||||
fallback={
|
<Box
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
component="aside"
|
||||||
<CircularProgress />
|
sx={{
|
||||||
</Box>
|
width: NAV_SIDEBAR_WIDTH,
|
||||||
}
|
flexShrink: 0,
|
||||||
|
position: 'sticky',
|
||||||
|
top: { xs: 56, sm: 64 },
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
height: { xs: 'calc(100vh - 56px)', sm: 'calc(100vh - 64px)' },
|
||||||
|
borderRight: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavSidebar categories={categories} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||||
<Route path="/" element={<MainPage adminMode={adminMode} />} />
|
<Suspense
|
||||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
fallback={
|
||||||
<Route path="/flavor/:id" element={<FlavorDetail adminMode={adminMode} />} />
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||||
<Route path="/profile/:username" element={<PublicProfile adminMode={adminMode} />} />
|
<CircularProgress />
|
||||||
<Route path="/community" element={<CommunityFeed adminMode={adminMode} />} />
|
|
||||||
<Route path="/about" element={<About />} />
|
|
||||||
<Route path="/privacy" element={<Privacy />} />
|
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
|
||||||
<Route
|
|
||||||
path="/settings"
|
|
||||||
element={<Settings themeName={themeName} onThemeChange={onThemeChange} />}
|
|
||||||
/>
|
|
||||||
<Route path="/support" element={<Support />} />
|
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
|
||||||
<Route path="/admin-panel" element={<AdminDashboard />} />
|
|
||||||
<Route path="/admin-panel/user/:id" element={<AdminUserDetail />} />
|
|
||||||
<Route path="/admin-panel/rating/:id" element={<AdminRatingDetail />} />
|
|
||||||
<Route path="/admin-panel/reply/:id" element={<AdminReplyDetail />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
element={
|
|
||||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
||||||
<Typography variant="h5">404 - Page Not Found</Typography>
|
|
||||||
<Button component={Link} to="/" sx={{ mt: 2 }}>
|
|
||||||
Back to Home
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
</Routes>
|
<Routes>
|
||||||
</Suspense>
|
<Route path="/" element={<MainPage />} />
|
||||||
|
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||||
|
<Route path="/flavor/:id" element={<FlavorDetail />} />
|
||||||
|
<Route path="/profile/:username" element={<PublicProfile />} />
|
||||||
|
<Route path="/community" element={<CommunityFeed />} />
|
||||||
|
<Route path="/about" element={<About />} />
|
||||||
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={<Settings themeName={themeName} onThemeChange={onThemeChange} />}
|
||||||
|
/>
|
||||||
|
<Route path="/support" element={<Support />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/forbidden" element={<Forbidden />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/admin-panel"
|
||||||
|
element={
|
||||||
|
<RequireSuperuser>
|
||||||
|
<AdminLayout />
|
||||||
|
</RequireSuperuser>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="overview" replace />} />
|
||||||
|
<Route path="overview" element={<AdminOverview />} />
|
||||||
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||||
|
<Route path="user/:id" element={<AdminUserDetail />} />
|
||||||
|
<Route path="banners" element={<AdminBanners />} />
|
||||||
|
<Route path="jobs" element={<AdminJobs />} />
|
||||||
|
<Route path="tickets" element={<AdminTickets />} />
|
||||||
|
<Route path="config" element={<AdminConfig />} />
|
||||||
|
<Route path="ratings/:id" element={<AdminRatingDetail />} />
|
||||||
|
<Route path="rating/:id" element={<AdminRatingDetail />} />
|
||||||
|
<Route path="replies/:id" element={<AdminReplyDetail />} />
|
||||||
|
<Route path="reply/:id" element={<AdminReplyDetail />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h5">404 - Page Not Found</Typography>
|
||||||
|
<Button component={Link} to="/" sx={{ mt: 2 }}>
|
||||||
|
Back to Home
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
<Footer />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Footer />
|
|
||||||
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
|
<CookieBanner onThemeChange={(name) => onThemeChange(name)} currentTheme={themeName} />
|
||||||
</Box>
|
</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;
|
is_superuser: boolean;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
drawer_anchor?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FollowingEntry {
|
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 { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui';
|
||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
|
|
||||||
interface CommunityFeedProps {
|
|
||||||
adminMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarCard: React.FC<{
|
const SidebarCard: React.FC<{
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
@@ -73,7 +69,7 @@ const SidebarCard: React.FC<{
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
const CommunityFeed: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useTitle(t('community.title'));
|
useTitle(t('community.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -212,7 +208,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
|||||||
<RatingBadge score={rating.score} />
|
<RatingBadge score={rating.score} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{adminMode && (
|
{user?.is_superuser && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -317,7 +313,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
|||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<RichText text={reply.text} />
|
<RichText text={reply.text} />
|
||||||
</Typography>
|
</Typography>
|
||||||
{adminMode && (
|
{user?.is_superuser && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }}
|
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,
|
EmptyState,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
|
|
||||||
interface MainPageProps {
|
|
||||||
adminMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOP_LIMIT = 5;
|
const TOP_LIMIT = 5;
|
||||||
|
|
||||||
const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
|
const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
|
||||||
@@ -81,7 +77,7 @@ const HeroCarouselSkeleton: React.FC = () => (
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
const MainPage: React.FC<MainPageProps> = (_props) => {
|
const MainPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|||||||
@@ -47,11 +47,7 @@ import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../com
|
|||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
|
|
||||||
interface PublicProfileProps {
|
const PublicProfile: React.FC = () => {
|
||||||
adminMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { username } = useParams<{ username: string }>();
|
const { username } = useParams<{ username: string }>();
|
||||||
@@ -635,7 +631,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Link>
|
</Link>
|
||||||
{adminMode && (
|
{currentUser?.is_superuser && (
|
||||||
<Box sx={{ px: 1, pb: 1 }}>
|
<Box sx={{ px: 1, pb: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
ListSubheader,
|
ListSubheader,
|
||||||
Divider,
|
Divider,
|
||||||
Grid,
|
Grid,
|
||||||
|
FormControlLabel,
|
||||||
|
Switch,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -32,6 +34,7 @@ import { useTitle } from '../hooks/useTitle';
|
|||||||
import type { CatppuccinTheme } from '../theme';
|
import type { CatppuccinTheme } from '../theme';
|
||||||
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
|
import { useDrawerAnchor } from '../hooks/useDrawerAnchor';
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
themeName: CatppuccinTheme;
|
themeName: CatppuccinTheme;
|
||||||
@@ -46,6 +49,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useTitle(t('nav.settings'));
|
useTitle(t('nav.settings'));
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
const { anchor: drawerAnchor, setAnchor: setDrawerAnchor } = useDrawerAnchor();
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
const { data: banners = [] } = useBannersList();
|
const { data: banners = [] } = useBannersList();
|
||||||
const updateAvatarMutation = useUpdateAvatar();
|
const updateAvatarMutation = useUpdateAvatar();
|
||||||
@@ -295,6 +299,31 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
|||||||
<MenuItem value="de">Deutsch</MenuItem>
|
<MenuItem value="de">Deutsch</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</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>
|
</GlassCard>
|
||||||
|
|
||||||
{/* Avatar: NOT a FormCard — file input lives outside any form */}
|
{/* Avatar: NOT a FormCard — file input lives outside any form */}
|
||||||
|
|||||||
+12
-152
@@ -11,46 +11,33 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
Avatar,
|
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useTickets } from '../api/queries/useTickets';
|
||||||
import { useMe } from '../api/queries/useMe';
|
|
||||||
import { useNotificationsQuery } from '../api/queries/useNotifications';
|
import { useNotificationsQuery } from '../api/queries/useNotifications';
|
||||||
import {
|
import { useAddTicketMessage, useCreateTicket } from '../api/mutations/useTicketMutations';
|
||||||
useAddTicketMessage,
|
|
||||||
useCreateTicket,
|
|
||||||
useDeleteTicket,
|
|
||||||
useUpdateTicketStatus,
|
|
||||||
} from '../api/mutations/useTicketMutations';
|
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
import { formatDate } from '../utils/date';
|
import { formatDate } from '../utils/date';
|
||||||
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
|
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
|
||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
type ChipStatusColor = 'error' | 'warning' | 'success' | 'default';
|
type ChipStatusColor = 'error' | 'warning' | 'success' | 'default';
|
||||||
type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary';
|
|
||||||
|
|
||||||
const Support: React.FC = () => {
|
const Support: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useTitle(t('support.title'));
|
useTitle(t('support.title'));
|
||||||
|
const { user, loadingUser } = useAuth();
|
||||||
const { data: tickets = [], isLoading: loading } = useTickets();
|
const { data: tickets = [], isLoading: loading } = useTickets();
|
||||||
const { data: currentUser = null } = useMe();
|
|
||||||
const { data: notifications = [] } = useNotificationsQuery(true);
|
const { data: notifications = [] } = useNotificationsQuery(true);
|
||||||
const createTicketMutation = useCreateTicket();
|
const createTicketMutation = useCreateTicket();
|
||||||
const addMessage = useAddTicketMessage();
|
const addMessage = useAddTicketMessage();
|
||||||
const updateStatusMutation = useUpdateTicketStatus();
|
|
||||||
const deleteTicketMutation = useDeleteTicket();
|
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
@@ -59,14 +46,10 @@ const Support: React.FC = () => {
|
|||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
const [readTicketIds, setReadTicketIds] = useState<Set<number>>(new Set());
|
const [readTicketIds, setReadTicketIds] = useState<Set<number>>(new Set());
|
||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
const { confirm } = useConfirm();
|
|
||||||
|
|
||||||
const unreadTicketIds = useMemo(() => {
|
const unreadTicketIds = useMemo(() => {
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
notifications.forEach((n) => {
|
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;
|
const ticketId = (n as unknown as { ticket?: number | null }).ticket;
|
||||||
if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) {
|
if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) {
|
||||||
if (!readTicketIds.has(ticketId)) ids.add(ticketId);
|
if (!readTicketIds.has(ticketId)) ids.add(ticketId);
|
||||||
@@ -93,6 +76,10 @@ const Support: React.FC = () => {
|
|||||||
setSubject('');
|
setSubject('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
|
notify({
|
||||||
|
message: t('support.ticketCreated', { defaultValue: 'Ticket created' }),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to create ticket', severity: 'error' });
|
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 => {
|
const getChipColor = (status: string): ChipStatusColor => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'open':
|
case 'open':
|
||||||
@@ -139,18 +109,9 @@ const Support: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getButtonColor = (status: string): ButtonStatusColor => {
|
if (!loadingUser && user?.is_superuser) {
|
||||||
switch (status) {
|
return <Navigate to="/admin-panel/tickets" replace />;
|
||||||
case 'open':
|
}
|
||||||
return 'error';
|
|
||||||
case 'in_progress':
|
|
||||||
return 'warning';
|
|
||||||
case 'resolved':
|
|
||||||
return 'success';
|
|
||||||
default:
|
|
||||||
return 'primary';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
@@ -215,10 +176,7 @@ const Support: React.FC = () => {
|
|||||||
</FormCard>
|
</FormCard>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<SectionHeader
|
<SectionHeader title={t('support.myTickets')} compact />
|
||||||
title={currentUser?.is_superuser ? `${t('admin.users')} Tickets` : t('support.myTickets')}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
{tickets.length === 0 ? (
|
{tickets.length === 0 ? (
|
||||||
<EmptyState title={t('support.noTickets')} />
|
<EmptyState title={t('support.noTickets')} />
|
||||||
@@ -258,15 +216,6 @@ const Support: React.FC = () => {
|
|||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
{currentUser?.is_superuser && (
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
variant="caption"
|
|
||||||
sx={{ ml: 1, color: 'primary.main' }}
|
|
||||||
>
|
|
||||||
({ticket.username})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{formatDate(ticket.created_at)}
|
{formatDate(ticket.created_at)}
|
||||||
@@ -288,75 +237,8 @@ const Support: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
sx={{ fontWeight: 'bold' }}
|
sx={{ fontWeight: 'bold' }}
|
||||||
/>
|
/>
|
||||||
{currentUser?.is_superuser && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteTicket(ticket.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
|
|
||||||
<Collapse in={expandedId === ticket.id}>
|
<Collapse in={expandedId === ticket.id}>
|
||||||
@@ -364,28 +246,6 @@ const Support: React.FC = () => {
|
|||||||
<Box
|
<Box
|
||||||
sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}
|
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
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
paragraph
|
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 { useToast } from '../../hooks/useToast';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
|
||||||
interface FlavorDetailProps {
|
const FlavorDetail: React.FC = () => {
|
||||||
adminMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -70,6 +66,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
|||||||
const { data: flavor, isLoading: loading } = useFlavorDetail(id);
|
const { data: flavor, isLoading: loading } = useFlavorDetail(id);
|
||||||
const { data: meData } = useCurrentUserLite();
|
const { data: meData } = useCurrentUserLite();
|
||||||
const currentUser = meData?.username ?? null;
|
const currentUser = meData?.username ?? null;
|
||||||
|
const isSuperuser = !!meData?.is_superuser;
|
||||||
const updateFlavor = useUpdateFlavor();
|
const updateFlavor = useUpdateFlavor();
|
||||||
const deleteRatingMutation = useDeleteRating();
|
const deleteRatingMutation = useDeleteRating();
|
||||||
const updateRating = useUpdateRating();
|
const updateRating = useUpdateRating();
|
||||||
@@ -257,7 +254,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
|||||||
/>
|
/>
|
||||||
<GlassCard sx={{ mt: { xs: 0, lg: 2 } }}>
|
<GlassCard sx={{ mt: { xs: 0, lg: 2 } }}>
|
||||||
<CardContent sx={{ p: 3 }}>
|
<CardContent sx={{ p: 3 }}>
|
||||||
{adminMode && (
|
{isSuperuser && (
|
||||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
{!isAdminEditing ? (
|
{!isAdminEditing ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -522,7 +519,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<RatingBadge score={rating.score} />
|
<RatingBadge score={rating.score} />
|
||||||
{adminMode && (
|
{isSuperuser && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -686,7 +683,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{adminMode && (
|
{isSuperuser && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
|
|||||||
Reference in new issue
Block a user