mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 17:07:01 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
commit
6f44983225
12 files changed
+994
-24
No files matched your search
@@ -0,0 +1,56 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-17 22:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0014_alter_user_theme_notification'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subject', models.CharField(max_length=200)),
|
||||
('description', models.TextField()),
|
||||
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='api.ticket')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserIP',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('last_login', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ips', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-last_login'],
|
||||
'unique_together': {('user', 'ip_address')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -138,3 +138,38 @@ class Notification(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type} for {self.recipient.username}"
|
||||
|
||||
class UserIP(models.Model):
|
||||
user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
last_login = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'ip_address')
|
||||
ordering = ['-last_login']
|
||||
|
||||
class Ticket(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('open', 'Open'),
|
||||
('in_progress', 'In Progress'),
|
||||
('resolved', 'Resolved'),
|
||||
('closed', 'Closed'),
|
||||
]
|
||||
user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE)
|
||||
subject = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
class TicketMessage(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
@@ -7,10 +7,11 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
|
||||
is_following = serializers.SerializerMethodField()
|
||||
unread_notifications_count = serializers.SerializerMethodField()
|
||||
is_superuser = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count']
|
||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser']
|
||||
|
||||
def get_avatar(self, obj):
|
||||
if obj.avatar:
|
||||
@@ -29,6 +30,41 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
def get_unread_notifications_count(self, obj):
|
||||
return obj.notifications.filter(is_read=False).count()
|
||||
|
||||
class UserIPSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Notification # Temporary placeholder to avoid import issues before I finish the file
|
||||
fields = []
|
||||
|
||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True)
|
||||
|
||||
class Meta:
|
||||
from .models import TicketMessage
|
||||
model = TicketMessage
|
||||
fields = ['id', 'username', 'text', 'created_at', 'is_admin']
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
from .models import Ticket
|
||||
model = Ticket
|
||||
fields = ['id', 'user', 'username', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
|
||||
read_only_fields = ['status', 'user']
|
||||
|
||||
class AdminUserSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings']
|
||||
|
||||
def get_ips(self, obj):
|
||||
return [ip.ip_address for ip in obj.ips.all()]
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
actor_username = serializers.CharField(source='actor.username', read_only=True)
|
||||
actor_avatar = serializers.SerializerMethodField()
|
||||
|
||||
@@ -9,6 +9,8 @@ router.register('categories', views.CategoryViewSet)
|
||||
router.register('ratings', views.RatingViewSet)
|
||||
router.register('replies', views.ReplyViewSet)
|
||||
router.register('notifications', views.NotificationViewSet, basename='notification')
|
||||
router.register('tickets', views.TicketViewSet, basename='ticket')
|
||||
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
+105
-5
@@ -7,8 +7,18 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification
|
||||
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer
|
||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP
|
||||
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer, TicketSerializer, TicketMessageSerializer, AdminUserSerializer
|
||||
|
||||
def log_user_ip(user, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if ip and user.is_authenticated:
|
||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
||||
|
||||
def parse_mentions(text, actor, rating=None, reply=None):
|
||||
import re
|
||||
@@ -175,7 +185,7 @@ class RatingViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
super().check_object_permissions(request, obj)
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user:
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser:
|
||||
self.permission_denied(request, message='You cannot edit/delete this rating.')
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
@@ -218,14 +228,14 @@ class ReplyViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def perform_update(self, serializer):
|
||||
reply = self.get_object()
|
||||
if reply.user != self.request.user:
|
||||
if reply.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("You cannot edit this reply.")
|
||||
new_reply = serializer.save()
|
||||
# Parse mentions in the updated text
|
||||
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.user != self.request.user:
|
||||
if instance.user != self.request.user and not self.request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("You cannot delete this reply.")
|
||||
instance.delete()
|
||||
|
||||
@@ -348,9 +358,99 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
log_user_ip(request.user, request)
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
class TicketViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Ticket.objects.all()
|
||||
return Ticket.objects.filter(user=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def add_message(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def update_status(self, request, pk=None):
|
||||
ticket = self.get_object()
|
||||
new_status = request.data.get('status')
|
||||
if new_status in dict(Ticket.STATUS_CHOICES):
|
||||
ticket.status = new_status
|
||||
ticket.save()
|
||||
return Response({'status': 'updated'})
|
||||
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class AdminViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
return Response({
|
||||
'total_users': User.objects.count(),
|
||||
'total_ratings': Rating.objects.count(),
|
||||
'total_replies': Reply.objects.count(),
|
||||
'open_tickets': Ticket.objects.filter(status='open').count(),
|
||||
'email_config': {
|
||||
'host': getattr(settings, 'EMAIL_HOST', 'None'),
|
||||
'port': getattr(settings, 'EMAIL_PORT', 'None'),
|
||||
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
|
||||
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
|
||||
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
|
||||
}
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def send_test_email(self, request):
|
||||
try:
|
||||
send_mail(
|
||||
'Holy Flavors Admin Test Email',
|
||||
f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.',
|
||||
'noreply@holyflavors.com',
|
||||
[request.user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'Test email sent!'})
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def users(self, request):
|
||||
users = User.objects.all().prefetch_related('ips').order_by('-date_joined')
|
||||
return Response(AdminUserSerializer(users, many=True).data)
|
||||
|
||||
@action(detail=True, methods=['get', 'patch', 'delete'])
|
||||
def user_detail(self, request, pk=None):
|
||||
try:
|
||||
user = User.objects.get(pk=pk)
|
||||
except User.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == 'GET':
|
||||
return Response(AdminUserSerializer(user).data)
|
||||
elif request.method == 'PATCH':
|
||||
is_active = request.data.get('is_active')
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user.save()
|
||||
return Response(AdminUserSerializer(user).data)
|
||||
elif request.method == 'DELETE':
|
||||
user.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
def update_preferences(self, request):
|
||||
user = request.user
|
||||
|
||||
+28
-6
@@ -54,6 +54,9 @@ const About = lazy(() => import('./pages/About'));
|
||||
const Privacy = lazy(() => import('./pages/Privacy'));
|
||||
const Login = lazy(() => import('./pages/Login'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const Support = lazy(() => import('./pages/Support'));
|
||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
@@ -206,7 +209,7 @@ const App: React.FC = () => {
|
||||
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [categories, setCategories] = useState<{name: string, slug: string}[]>([]);
|
||||
const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number} | null>(null);
|
||||
const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number, is_superuser: boolean} | null>(null);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
@@ -392,6 +395,19 @@ const App: React.FC = () => {
|
||||
<ListItemText primary={t('nav.settings')} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component={Link} to="/support" onClick={() => setDrawerOpen(false)}>
|
||||
<ListItemText primary={t('support.title')} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{user.is_superuser && (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component={Link} to="/admin" onClick={() => setDrawerOpen(false)}>
|
||||
<ListItemText primary="Admin Panel" sx={{ color: 'primary.main', fontWeight: 'bold' }} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{following.length > 0 && (
|
||||
<>
|
||||
@@ -574,11 +590,14 @@ const App: React.FC = () => {
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem component={Link} to={`/profile/${user.username}`} onClick={() => setAnchorEl(null)}>{t('nav.profile')}</MenuItem>
|
||||
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>{t('nav.dashboard')}</MenuItem>
|
||||
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>{t('nav.settings')}</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>{t('nav.logout')}</MenuItem>
|
||||
</Menu>
|
||||
<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 component={Link} to="/admin" onClick={() => setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>{t('nav.logout')}</MenuItem> </Menu>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: isMobile ? 'none' : 'block' }}>
|
||||
@@ -621,6 +640,9 @@ const App: React.FC = () => {
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
|
||||
<Route path="/support" element={<Support />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/admin/user/:id" element={<AdminUserDetail />} />
|
||||
<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>
|
||||
|
||||
@@ -130,6 +130,37 @@ const resources = {
|
||||
"writeReply": "Add a reply...",
|
||||
"rated": "rated"
|
||||
},
|
||||
"support": {
|
||||
"title": "Help & Support",
|
||||
"myTickets": "My Tickets",
|
||||
"createTicket": "Open New Ticket",
|
||||
"subject": "Subject",
|
||||
"description": "How can we help you?",
|
||||
"status": "Status",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"resolved": "Resolved",
|
||||
"in_progress": "In Progress",
|
||||
"noTickets": "You haven't opened any tickets yet.",
|
||||
"reply": "Send Message",
|
||||
"ticketDetail": "Ticket #{{id}}"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Control Panel",
|
||||
"users": "User Management",
|
||||
"stats": "System Statistics",
|
||||
"config": "Server Configuration",
|
||||
"totalUsers": "Total Users",
|
||||
"openTickets": "Open Tickets",
|
||||
"sendTestEmail": "Send Test Email",
|
||||
"testEmailSuccess": "Test email sent successfully!",
|
||||
"ipHistory": "Login IP History",
|
||||
"manageUser": "Manage User",
|
||||
"deactivate": "Deactivate Account",
|
||||
"activate": "Activate Account",
|
||||
"dangerZone": "Danger Zone",
|
||||
"deleteUser": "Delete User Permanently"
|
||||
},
|
||||
"about": {
|
||||
"title": "About This Project",
|
||||
"mission": "Holy Flavors Archive is a community-driven project dedicated to documenting and rating the vast world of Holy Energy products. Our goal is to create a definitive, high-density catalog of every flavor ever released—including legacy and limited editions.",
|
||||
@@ -286,6 +317,37 @@ const resources = {
|
||||
"writeReply": "Eine Antwort hinzufügen...",
|
||||
"rated": "bewertete"
|
||||
},
|
||||
"support": {
|
||||
"title": "Hilfe & Support",
|
||||
"myTickets": "Meine Tickets",
|
||||
"createTicket": "Neues Ticket öffnen",
|
||||
"subject": "Betreff",
|
||||
"description": "Wie können wir helfen?",
|
||||
"status": "Status",
|
||||
"open": "Offen",
|
||||
"closed": "Geschlossen",
|
||||
"resolved": "Gelöst",
|
||||
"in_progress": "In Bearbeitung",
|
||||
"noTickets": "Du hast noch keine Tickets geöffnet.",
|
||||
"reply": "Nachricht senden",
|
||||
"ticketDetail": "Ticket #{{id}}"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin-Kontrollzentrum",
|
||||
"users": "Benutzerverwaltung",
|
||||
"stats": "System-Statistiken",
|
||||
"config": "Server-Konfiguration",
|
||||
"totalUsers": "Benutzer gesamt",
|
||||
"openTickets": "Offene Tickets",
|
||||
"sendTestEmail": "Test-E-Mail senden",
|
||||
"testEmailSuccess": "Test-E-Mail erfolgreich gesendet!",
|
||||
"ipHistory": "Login-IP-Verlauf",
|
||||
"manageUser": "Benutzer verwalten",
|
||||
"deactivate": "Konto deaktivieren",
|
||||
"activate": "Konto aktivieren",
|
||||
"dangerZone": "Gefahrenzone",
|
||||
"deleteUser": "Benutzer dauerhaft löschen"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über dieses Projekt",
|
||||
"mission": "Das Holy Flavors Archiv ist ein von der Community getriebenes Projekt, das sich der Dokumentation und Bewertung der riesigen Welt der Holy Energy Produkte widmet. Unser Ziel ist es, einen definitiven Katalog jeder jemals veröffentlichten Sorte zu erstellen – einschließlich Legacy- und Limited-Editionen.",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
last_login: string;
|
||||
ips: string[];
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('admin.title'));
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [statsRes, usersRes] = await Promise.all([
|
||||
api.get('admin-custom/stats/'),
|
||||
api.get('admin-custom/users/')
|
||||
]);
|
||||
setStats(statsRes.data);
|
||||
setUsers(usersRes.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
try {
|
||||
await api.post('admin-custom/send_test_email/');
|
||||
alert(t('admin.testEmailSuccess'));
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.response?.data?.error || 'Failed to send'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.title')}</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent>
|
||||
<Typography variant="overline">{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent>
|
||||
<Typography variant="overline">{t('admin.openTickets')}</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{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" startIcon={<EmailIcon />} onClick={handleSendTestEmail}>
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{t('admin.users')}</Typography>
|
||||
<TextField
|
||||
size="small" placeholder="Search..."
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: 300 }}
|
||||
/>
|
||||
</Stack>
|
||||
<TableContainer component={Paper} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<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>
|
||||
{filteredUsers.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/user/${user.id}`)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
Stack, alpha, CircularProgress, Chip, Paper, List, ListItem, ListItemText,
|
||||
Avatar, Grid
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
last_login: string;
|
||||
ips: string[];
|
||||
ratings: any[];
|
||||
}
|
||||
|
||||
const AdminUserDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await api.get(`admin-custom/${id}/user_detail/`);
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
|
||||
fetchUser();
|
||||
} catch (err) { alert('Action failed'); }
|
||||
};
|
||||
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!confirm('Delete this rating?')) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!confirm('Delete this reply?')) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.delete(`admin-custom/${id}/user_detail/`);
|
||||
navigate('/admin');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (!user) return <Typography>User not found</Typography>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin')}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Card elevation={0} sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Stack direction="row" spacing={3} alignItems="center">
|
||||
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main' }}>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
<Typography variant="body1" color="text.secondary">{user.email}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" />
|
||||
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={user.is_active ? "warning" : "success"}
|
||||
onClick={handleToggleActive}
|
||||
>
|
||||
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack spacing={4}>
|
||||
<Paper sx={{ p: 3, borderRadius: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SecurityIcon /> {t('admin.ipHistory')}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{user.ips.map((ip, idx) => (
|
||||
<ListItem key={idx}>
|
||||
<ListItemText primary={ip} />
|
||||
</ListItem>
|
||||
))}
|
||||
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}>
|
||||
<Typography variant="h6" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Deleting this user will permanently remove their profile, ratings, replies, and all associated metadata.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
fullWidth
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Content</Typography>
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map(rating => (
|
||||
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{rating.flavor_name}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Chip label={`${rating.score}/10`} size="small" color="primary" />
|
||||
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{rating.comment || <i>No comment</i>}
|
||||
</Typography>
|
||||
|
||||
{rating.replies.length > 0 && (
|
||||
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box key={reply.id} sx={{ mb: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{reply.user}</Typography>
|
||||
<Button size="small" color="error" sx={{ minWidth: 0, p: 0 }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" display="block">{reply.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{user.ratings.length === 0 && <Typography color="text.secondary">No ratings yet.</Typography>}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserDetail;
|
||||
@@ -64,6 +64,11 @@ const Privacy: React.FC = () => {
|
||||
Ihre E-Mail-Adresse und Ihr Passwort (verschlüsselt). Diese Daten werden zur Bereitstellung der
|
||||
Community-Funktionen (Bewertungen, Folgen von Nutzern) verwendet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
<strong>IP-Adressen:</strong> Zur Gewährleistung der Sicherheit unserer IT-Systeme und zur Missbrauchsverhinderung
|
||||
speichern wir die IP-Adressen, von denen aus Sie sich anmelden. Diese Daten werden gelöscht, sobald sie für
|
||||
die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO.
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
<strong>Öffentliches Profil:</strong> Ihr Benutzername, Ihr Avatar und Ihre Bewertungen sind für
|
||||
andere Nutzer öffentlich sichtbar. Sie können diese Daten jederzeit in den Einstellungen ändern oder löschen.
|
||||
@@ -108,6 +113,11 @@ const Privacy: React.FC = () => {
|
||||
and password (encrypted). This data is used to provide community features (ratings, following users).
|
||||
The legal basis is Art. 6 Para. 1 lit. b GDPR.
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
<strong>IP Addresses:</strong> To ensure the security of our IT systems and to prevent abuse,
|
||||
we store the IP addresses from which you log in. This data is deleted as soon as it is no longer
|
||||
necessary to achieve the purpose for which it was collected. The legal basis is Art. 6 Para. 1 lit. f GDPR.
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
<strong>Public Profile:</strong> Your username, avatar, and ratings are publicly visible to other users.
|
||||
You can change or delete this data at any time in your settings.
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Tab,
|
||||
Paper,
|
||||
Divider,
|
||||
Button
|
||||
Button,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -40,6 +41,7 @@ interface ProfileData {
|
||||
following_count: number;
|
||||
followers_count: number;
|
||||
is_following: boolean;
|
||||
is_superuser?: boolean;
|
||||
ratings: Rating[];
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ const PublicProfile: React.FC = () => {
|
||||
const [data, setData] = useState<ProfileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<{username: string, is_superuser: boolean} | null>(null);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
@@ -68,7 +70,7 @@ const PublicProfile: React.FC = () => {
|
||||
localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null })
|
||||
]);
|
||||
setData(profileRes.data);
|
||||
if (meRes.data) setCurrentUser(meRes.data.username);
|
||||
if (meRes.data) setCurrentUser(meRes.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
@@ -157,15 +159,27 @@ const PublicProfile: React.FC = () => {
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
|
||||
{currentUser !== data.username && (
|
||||
<Button
|
||||
variant={data.is_following ? "outlined" : "contained"}
|
||||
onClick={handleFollowToggle}
|
||||
sx={{ borderRadius: 4, px: 4 }}
|
||||
>
|
||||
{data.is_following ? "Following" : "Follow"}
|
||||
</Button>
|
||||
)}
|
||||
<Stack direction="row" spacing={1}>
|
||||
{currentUser?.username !== data.username && (
|
||||
<Button
|
||||
variant={data.is_following ? "outlined" : "contained"}
|
||||
onClick={handleFollowToggle}
|
||||
sx={{ borderRadius: 4, px: 4 }}
|
||||
>
|
||||
{data.is_following ? "Following" : "Follow"}
|
||||
</Button>
|
||||
)}
|
||||
{currentUser?.is_superuser && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/admin/user/${data.id}`)}
|
||||
sx={{ borderRadius: 4, px: 4 }}
|
||||
>
|
||||
{t('admin.manageUser')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
username: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
username: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
created_at: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
const Support: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useTitle(t('support.title'));
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
// Create form
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Reply state
|
||||
const [replyText, setReplyText] = useState<{[key: number]: string}>({});
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const [ticketRes, userRes] = await Promise.all([
|
||||
api.get('tickets/'),
|
||||
api.get('users/me/')
|
||||
]);
|
||||
setTickets(ticketRes.data.results || ticketRes.data);
|
||||
setCurrentUser(userRes.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('tickets/', { subject, description });
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
setShowCreate(false);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to create ticket');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (ticketId: number) => {
|
||||
const text = replyText[ticketId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/add_message/`, { text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (ticketId: number, status: string) => {
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/update_status/`, { status });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open': return 'error';
|
||||
case 'in_progress': return 'warning';
|
||||
case 'resolved': return 'success';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
|
||||
{!currentUser?.is_superuser && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showCreate}>
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}>
|
||||
<form onSubmit={handleCreate}>
|
||||
<TextField
|
||||
fullWidth label={t('support.subject')} margin="normal"
|
||||
value={subject} onChange={(e) => setSubject(e.target.value)} required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth multiline rows={4} label={t('support.description')} margin="normal"
|
||||
value={description} onChange={(e) => setDescription(e.target.value)} required
|
||||
/>
|
||||
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
|
||||
</Typography>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
|
||||
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{tickets.map((ticket: Ticket) => (
|
||||
<Card key={ticket.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box
|
||||
onClick={() => setExpandedId(expandedId === ticket.id ? null : ticket.id)}
|
||||
sx={{ p: 2, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{ticket.subject}
|
||||
{currentUser?.is_superuser && (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
|
||||
({ticket.username})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
|
||||
</Box>
|
||||
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
|
||||
</Box>
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
|
||||
{currentUser?.is_superuser && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
|
||||
<Button
|
||||
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
|
||||
onClick={() => handleUpdateStatus(ticket.id, s)}
|
||||
color={getStatusColor(s) as any}
|
||||
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem' }}
|
||||
>
|
||||
{t(`support.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3 }}>
|
||||
{ticket.description}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{ticket.messages.map((msg: Message) => (
|
||||
<Box key={msg.id} sx={{
|
||||
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
|
||||
maxWidth: '80%',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
</Typography>
|
||||
<Typography variant="body2">{msg.text}</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7 }}>{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 })}
|
||||
/>
|
||||
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Support;
|
||||
Reference in new issue
Block a user