diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c476494..a58cf83 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,6 +57,8 @@ const Settings = lazy(() => import('./pages/Settings')); const Support = lazy(() => import('./pages/Support')); const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); +const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail')); +const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail')); interface SearchResult { id: number; @@ -215,8 +217,15 @@ const App: React.FC = () => { const [loadingUser, setLoadingUser] = useState(true); const [drawerOpen, setDrawerOpen] = useState(false); const [catOpen, setCatOpen] = useState(false); + const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true'); const isMobile = useMediaQuery('(max-width:900px)'); + const toggleAdminMode = () => { + const newVal = !adminMode; + setAdminMode(newVal); + localStorage.setItem('admin-mode', String(newVal)); + }; + const fetchNotifications = async () => { try { const res = await api.get('notifications/'); @@ -403,7 +412,19 @@ const App: React.FC = () => { {user.is_superuser && ( - setDrawerOpen(false)}> + + + + + )} + + {user.is_superuser && ( + + setDrawerOpen(false)}> @@ -592,11 +613,15 @@ const App: React.FC = () => { setAnchorEl(null)}>{t('nav.profile')} setAnchorEl(null)}>{t('nav.dashboard')} setAnchorEl(null)}>{t('nav.settings')} - setAnchorEl(null)}>{t('support.title')} - {user.is_superuser && ( - setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel - )} - + setAnchorEl(null)}>{t('support.title')} + {user.is_superuser && ( + + {t('admin.adminMode')}: {adminMode ? "ON" : "OFF"} + + )} + {user.is_superuser && ( + setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel + )} {t('nav.logout')} ) : ( @@ -631,18 +656,20 @@ const App: React.FC = () => { }> - } /> + } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> 404 - Page Not Found} /> diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ac64f52..9c6b9ed 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -156,6 +156,9 @@ const resources = { "testEmailSuccess": "Test email sent successfully!", "ipHistory": "Login IP History", "manageUser": "Manage User", + "manageRating": "Manage Rating", + "manageReply": "Manage Reply", + "adminMode": "Admin Mode", "deactivate": "Deactivate Account", "activate": "Activate Account", "dangerZone": "Danger Zone", @@ -342,8 +345,11 @@ const resources = { "sendTestEmail": "Test-E-Mail senden", "testEmailSuccess": "Test-E-Mail erfolgreich gesendet!", "ipHistory": "Login-IP-Verlauf", - "manageUser": "Benutzer verwalten", - "deactivate": "Konto deaktivieren", + "manageUser": "Manage User", + "manageRating": "Manage Rating", + "manageReply": "Manage Reply", + "adminMode": "Admin Mode", + "deactivate": "Deactivate Account", "activate": "Konto aktivieren", "dangerZone": "Gefahrenzone", "deleteUser": "Benutzer dauerhaft löschen" diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 8400b9b..af79e0a 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react'; import { Container, Typography, Box, Grid, Card, CardContent, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton + Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton, + useMediaQuery } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import EmailIcon from '@mui/icons-material/Email'; @@ -40,6 +41,7 @@ interface AdminUser { const AdminDashboard: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const isMobile = useMediaQuery('(max-width:900px)'); useTitle(t('admin.title')); const [stats, setStats] = useState(null); const [users, setUsers] = useState([]); @@ -84,38 +86,38 @@ const AdminDashboard: React.FC = () => { return ( - {t('admin.title')} + {t('admin.title')} - - + + alpha(theme.palette.primary.main, 0.1) }}> - - {t('admin.totalUsers')} - {stats?.total_users} + + {t('admin.totalUsers')} + {stats?.total_users} - + alpha(theme.palette.error.main, 0.1) }}> - - {t('admin.openTickets')} - {stats?.open_tickets} + + {t('admin.openTickets')} + {stats?.open_tickets} - - + + {t('admin.config')}: SMTP - + {stats?.email_config.host}:{stats?.email_config.port} • TLS: {String(stats?.email_config.use_tls)} • Insecure: {String(stats?.email_config.skip_verify)} - @@ -125,62 +127,86 @@ const AdminDashboard: React.FC = () => { - + {t('admin.users')} setSearch(e.target.value)} - sx={{ width: 300 }} + sx={{ width: isMobile ? '100%' : 300 }} /> - - - - - ID - User - Email - Status - Joined - IP History - Actions - - - - {filteredUsers.map((user) => ( - - {user.id} - - {user.username} - {user.is_superuser && } - - {user.email} - - - - {formatDate(user.date_joined)} - - - {user.ips.slice(0, 3).map(ip => ( - - ))} - {user.ips.length > 3 && +{user.ips.length - 3}} - - - - navigate(`/admin/user/${user.id}`)}> + + {isMobile ? ( + + {filteredUsers.map(user => ( + + + + {user.username} + navigate(`/admin-panel/user/${user.id}`)}> - + + {user.email} + + + {user.is_superuser && } + + + + ))} + + ) : ( + +
+ + + ID + User + Email + Status + Joined + IP History + Actions - ))} - -
-
+ + + {filteredUsers.map((user) => ( + + {user.id} + + {user.username} + {user.is_superuser && } + + {user.email} + + + + {formatDate(user.date_joined)} + + + {user.ips.slice(0, 3).map(ip => ( + + ))} + {user.ips.length > 3 && +{user.ips.length - 3}} + + + + navigate(`/admin-panel/user/${user.id}`)}> + + + + + ))} + + + + )}
); diff --git a/frontend/src/pages/AdminRatingDetail.tsx b/frontend/src/pages/AdminRatingDetail.tsx new file mode 100644 index 0000000..a7ee2a6 --- /dev/null +++ b/frontend/src/pages/AdminRatingDetail.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, Typography, Box, Button, + Stack, CircularProgress, TextField, Paper +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import api from '../api'; +import { useTitle } from '../hooks/useTitle'; +import MentionTextField from '../components/MentionTextField'; + +const AdminRatingDetail: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [score, setScore] = useState(0); + const [comment, setComment] = useState(''); + const [rating, setRating] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchRating = async () => { + try { + const res = await api.get(`ratings/${id}/`); + setRating(res.data); + setScore(res.data.score); + setComment(res.data.comment || ''); + } catch (err) { + console.error(err); + navigate('/admin-panel'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchRating(); + }, [id]); + + useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); + + const handleUpdate = async () => { + try { + await api.patch(`ratings/${id}/`, { score, comment }); + fetchRating(); + alert('Rating updated!'); + } catch (err) { alert('Update failed'); } + }; + + const handleDelete = async () => { + if (!window.confirm('Delete this rating and all replies?')) return; + try { + await api.delete(`ratings/${id}/`); + navigate('/admin-panel'); + } catch (err) { alert('Delete failed'); } + }; + + if (loading) return ; + + return ( + + + + {t('admin.manageRating')} + + + + {rating.flavor_name} (User: {rating.user}) + + + setScore(Number(e.target.value))} + /> + + + + + + + + + ); +}; + +export default AdminRatingDetail; diff --git a/frontend/src/pages/AdminReplyDetail.tsx b/frontend/src/pages/AdminReplyDetail.tsx new file mode 100644 index 0000000..4854a5b --- /dev/null +++ b/frontend/src/pages/AdminReplyDetail.tsx @@ -0,0 +1,92 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, Typography, Box, Button, + Stack, CircularProgress, Paper +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import api from '../api'; +import { useTitle } from '../hooks/useTitle'; +import MentionTextField from '../components/MentionTextField'; + +const AdminReplyDetail: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [text, setText] = useState(''); + const [reply, setReply] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchReply = async () => { + try { + // We use the replies list/detail endpoint + const res = await api.get(`replies/${id}/`); + setReply(res.data); + setText(res.data.text); + } catch (err) { + console.error(err); + navigate('/admin-panel'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchReply(); + }, [id]); + + useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); + + const handleUpdate = async () => { + try { + await api.patch(`replies/${id}/`, { text }); + fetchReply(); + alert('Reply updated!'); + } catch (err) { alert('Update failed'); } + }; + + const handleDelete = async () => { + if (!window.confirm('Delete this reply?')) return; + try { + await api.delete(`replies/${id}/`); + navigate('/admin-panel'); + } catch (err) { alert('Delete failed'); } + }; + + if (loading) return ; + + return ( + + + + {t('admin.manageReply')} + + + + Reply by {reply.user} + + + + + + + + + + + ); +}; + +export default AdminReplyDetail; diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 5452d5a..bc7c4e0 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -79,7 +79,11 @@ interface Notification { flavor_id: number | null; } -const CommunityFeed: React.FC = () => { +interface CommunityFeedProps { + adminMode?: boolean; +} + +const CommunityFeed: React.FC = ({ adminMode }) => { const { t } = useTranslation(); useTitle(t('community.title')); const navigate = useNavigate(); @@ -274,6 +278,16 @@ const CommunityFeed: React.FC = () => { + {adminMode && ( + + )} + {/* Flavor and Comment */} @@ -315,7 +329,17 @@ const CommunityFeed: React.FC = () => { {reply.user} {formatTimestamp(reply.created_at)} - + + + {adminMode && ( + + )} + ))} diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 736ae9e..a3e9291 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -22,6 +22,7 @@ import api from '../api'; import { useTitle } from '../hooks/useTitle'; import { formatDate } from '../utils/date'; import RatingBadge from '../components/RatingBadge'; +import { useNavigate } from 'react-router-dom'; interface Flavor { id: number; @@ -47,8 +48,13 @@ interface Review { flavor: number; } -const MainPage: React.FC = () => { +interface MainPageProps { + adminMode?: boolean; +} + +const MainPage: React.FC = ({ adminMode }) => { const { t } = useTranslation(); + const navigate = useNavigate(); const [topFlavors, setTopFlavors] = useState([]); const [newestFlavors, setNewestFlavors] = useState([]); const [recentReviews, setRecentReviews] = useState([]); @@ -282,6 +288,16 @@ const MainPage: React.FC = () => { {t('home.rated')} {formatDate(rating.created_at)} + + {adminMode && ( + + )} { + {adminMode && ( + + )} {formatDate(review.created_at)} • on {review.flavor_name} diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 6e7a76b..4774dbb 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -45,7 +45,11 @@ interface ProfileData { ratings: Rating[]; } -const PublicProfile: React.FC = () => { +interface PublicProfileProps { + adminMode?: boolean; +} + +const PublicProfile: React.FC = ({ adminMode }) => { const { t } = useTranslation(); const { username } = useParams<{ username: string }>(); const navigate = useNavigate(); @@ -265,6 +269,15 @@ const PublicProfile: React.FC = () => { }}> {rating.flavor_name} + {adminMode && ( + + )} diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index 03042a5..85f5f7c 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -61,7 +61,11 @@ interface Flavor { shop_url: string | null; } -const FlavorDetail: React.FC = () => { +interface FlavorDetailProps { + adminMode?: boolean; +} + +const FlavorDetail: React.FC = ({ adminMode }) => { const { t } = useTranslation(); const theme = useTheme(); const { id } = useParams<{ id: string }>(); @@ -403,7 +407,18 @@ const FlavorDetail: React.FC = () => { - + + + {adminMode && ( + + )} + {rating.comment ? ( @@ -457,6 +472,15 @@ const FlavorDetail: React.FC = () => { )} + {adminMode && ( + + )} {editingReplyId === reply.id ? (