mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
redesign taste profile
This commit is contained in:
1 parent
b096192b57
commit
a6e946444a
5 files changed
+450
-182
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-18 03:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('api', '0016_notification_ticket_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProfileComment',
|
||||||
|
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)),
|
||||||
|
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authored_profile_comments', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('profile_owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_comments', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -176,3 +176,12 @@ class TicketMessage(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['created_at']
|
ordering = ['created_at']
|
||||||
|
|
||||||
|
class ProfileComment(models.Model):
|
||||||
|
profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE)
|
||||||
|
author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE)
|
||||||
|
text = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
@@ -136,6 +136,23 @@ class TicketSerializer(serializers.ModelSerializer):
|
|||||||
return obj.user.avatar.url
|
return obj.user.avatar.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class ProfileCommentSerializer(serializers.ModelSerializer):
|
||||||
|
author_username = serializers.CharField(source='author.username', read_only=True)
|
||||||
|
author_avatar = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
from .models import ProfileComment
|
||||||
|
model = ProfileComment
|
||||||
|
fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at']
|
||||||
|
|
||||||
|
def get_author_avatar(self, obj):
|
||||||
|
if obj.author.avatar:
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request:
|
||||||
|
return request.build_absolute_uri(obj.author.avatar.url)
|
||||||
|
return obj.author.avatar.url
|
||||||
|
return None
|
||||||
|
|
||||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
class AdminUserListSerializer(serializers.ModelSerializer):
|
||||||
ips = serializers.SerializerMethodField()
|
ips = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
|||||||
+45
-5
@@ -8,8 +8,13 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from django_ratelimit.decorators import ratelimit
|
from django_ratelimit.decorators import ratelimit
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP
|
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment
|
||||||
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer, TicketSerializer, TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer
|
from .serializers import (
|
||||||
|
UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer,
|
||||||
|
ReplySerializer, NotificationSerializer, TicketSerializer,
|
||||||
|
TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer,
|
||||||
|
ProfileCommentSerializer
|
||||||
|
)
|
||||||
|
|
||||||
def log_user_ip(user, request):
|
def log_user_ip(user, request):
|
||||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
@@ -345,18 +350,53 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
|
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
|
||||||
|
comments = user.profile_comments.select_related('author').order_by('-created_at')
|
||||||
|
|
||||||
|
followers = user.followers.all()
|
||||||
|
following = user.following.all()
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'theme': user.theme,
|
'theme': user.theme,
|
||||||
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
|
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
|
||||||
'following_count': user.following.count(),
|
'following_count': following.count(),
|
||||||
'followers_count': user.followers.count(),
|
'followers_count': followers.count(),
|
||||||
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
|
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
|
||||||
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data
|
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data,
|
||||||
|
'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data,
|
||||||
|
'followers': UserSerializer(followers, many=True, context={'request': request}).data,
|
||||||
|
'following': UserSerializer(following, many=True, context={'request': request}).data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def add_comment(self, request, pk=None):
|
||||||
|
profile_owner = self.get_object()
|
||||||
|
text = request.data.get('text')
|
||||||
|
if not text:
|
||||||
|
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
comment = ProfileComment.objects.create(
|
||||||
|
profile_owner=profile_owner,
|
||||||
|
author=request.user,
|
||||||
|
text=text
|
||||||
|
)
|
||||||
|
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def delete_comment(self, request, pk=None, comment_id=None):
|
||||||
|
profile_owner = self.get_object()
|
||||||
|
try:
|
||||||
|
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
||||||
|
except ProfileComment.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user:
|
||||||
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
comment.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def me(self, request):
|
def me(self, request):
|
||||||
log_user_ip(request.user, request)
|
log_user_ip(request.user, request)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
@@ -8,19 +8,28 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Container,
|
Container,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Tooltip,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Tab,
|
Tab,
|
||||||
Paper,
|
Paper,
|
||||||
Divider,
|
Divider,
|
||||||
Button,
|
Button,
|
||||||
Stack
|
Stack,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Chip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { Link } from 'react-router-dom';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import RatingBadge from '../components/RatingBadge';
|
||||||
|
import RichText from '../components/RichText';
|
||||||
|
import { formatDate } from '../utils/date';
|
||||||
|
|
||||||
interface Rating {
|
interface Rating {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,6 +42,20 @@ interface Rating {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProfileComment {
|
||||||
|
id: number;
|
||||||
|
author_username: string;
|
||||||
|
author_avatar: string | null;
|
||||||
|
text: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MiniUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -41,8 +64,10 @@ interface ProfileData {
|
|||||||
following_count: number;
|
following_count: number;
|
||||||
followers_count: number;
|
followers_count: number;
|
||||||
is_following: boolean;
|
is_following: boolean;
|
||||||
is_superuser?: boolean;
|
|
||||||
ratings: Rating[];
|
ratings: Rating[];
|
||||||
|
comments: ProfileComment[];
|
||||||
|
followers: MiniUser[];
|
||||||
|
following: MiniUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicProfileProps {
|
interface PublicProfileProps {
|
||||||
@@ -51,36 +76,34 @@ interface PublicProfileProps {
|
|||||||
|
|
||||||
const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
const { username } = useParams<{ username: string }>();
|
const { username } = useParams<{ username: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<ProfileData | null>(null);
|
const [data, setData] = useState<ProfileData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0); // 0: Ratings, 1: Guestbook, 2: Followers, 3: Following
|
||||||
|
const [categoryTab, setCategoryTab] = useState(0);
|
||||||
const [currentUser, setCurrentUser] = useState<{username: string, is_superuser: boolean} | null>(null);
|
const [currentUser, setCurrentUser] = useState<{username: string, is_superuser: boolean} | null>(null);
|
||||||
|
|
||||||
|
// Comment state
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
|
||||||
const handleGoBack = () => {
|
const fetchProfile = async () => {
|
||||||
if (window.history.length > 1) {
|
try {
|
||||||
navigate(-1);
|
const [profileRes, meRes] = await Promise.all([
|
||||||
} else {
|
api.get(`users/profile/${username}/`),
|
||||||
navigate('/');
|
localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null })
|
||||||
}
|
]);
|
||||||
|
setData(profileRes.data);
|
||||||
|
if (meRes.data) setCurrentUser(meRes.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
|
||||||
try {
|
|
||||||
const [profileRes, meRes] = await Promise.all([
|
|
||||||
api.get(`users/profile/${username}/`),
|
|
||||||
localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null })
|
|
||||||
]);
|
|
||||||
setData(profileRes.data);
|
|
||||||
if (meRes.data) setCurrentUser(meRes.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
@@ -99,12 +122,34 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newComment.trim() || !data) return;
|
||||||
|
try {
|
||||||
|
await api.post(`users/${data.id}/add_comment/`, { text: newComment });
|
||||||
|
setNewComment('');
|
||||||
|
fetchProfile();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to add comment');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (commentId: number) => {
|
||||||
|
if (!data || !confirm('Delete this comment?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`users/${data.id}/delete_comment/${commentId}/`);
|
||||||
|
fetchProfile();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete comment');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useTitle(data ? `${data.username}'s Ratings` : 'Profile');
|
useTitle(data ? `${data.username}'s Ratings` : 'Profile');
|
||||||
|
|
||||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||||
if (!data) return <Typography>Profile not found.</Typography>;
|
if (!data) return <Typography>Profile not found.</Typography>;
|
||||||
|
|
||||||
// Group by category
|
// Rating filtering
|
||||||
const categoryGroups = data.ratings.reduce((acc: any, rating) => {
|
const categoryGroups = data.ratings.reduce((acc: any, rating) => {
|
||||||
const cat = rating.category_name || 'Other';
|
const cat = rating.category_name || 'Other';
|
||||||
if (!acc[cat]) acc[cat] = [];
|
if (!acc[cat]) acc[cat] = [];
|
||||||
@@ -112,181 +157,310 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const categories = ['All', ...Object.keys(categoryGroups)];
|
const ratingCategories = ['All', ...Object.keys(categoryGroups)];
|
||||||
const currentRatings = activeTab === 0 ? data.ratings : categoryGroups[categories[activeTab]];
|
const currentRatings = categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]];
|
||||||
|
|
||||||
// Tier logic
|
|
||||||
const getTier = (score: number) => {
|
|
||||||
if (score >= 9) return { label: 'S', color: '#ff7f7f', desc: 'Elite' };
|
|
||||||
if (score >= 7) return { label: 'A', color: '#ffbf7f', desc: 'Great' };
|
|
||||||
if (score >= 5) return { label: 'B', color: '#ffff7f', desc: 'Good' };
|
|
||||||
if (score >= 3) return { label: 'C', color: '#bfff7f', desc: 'Okay' };
|
|
||||||
return { label: 'D', color: '#7fff7f', desc: 'Avoid' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const tiers = [
|
const tiers = [
|
||||||
{ key: 'S', title: 'S-Tier', min: 9 },
|
{ key: 'S', title: 'S-Tier', min: 9, color: '#ff7f7f' },
|
||||||
{ key: 'A', title: 'A-Tier', min: 7 },
|
{ key: 'A', title: 'A-Tier', min: 7, color: '#ffbf7f' },
|
||||||
{ key: 'B', title: 'B-Tier', min: 5 },
|
{ key: 'B', title: 'B-Tier', min: 5, color: '#ffff7f' },
|
||||||
{ key: 'C', title: 'C-Tier', min: 3 },
|
{ key: 'C', title: 'C-Tier', min: 3, color: '#bfff7f' },
|
||||||
{ key: 'D', title: 'D-Tier', min: 0 },
|
{ key: 'D', title: 'D-Tier', min: 0, color: '#7fff7f' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
if (window.history.length > 1) navigate(-1);
|
||||||
|
else navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={handleGoBack}
|
onClick={handleGoBack}
|
||||||
startIcon={<ArrowBackIcon />}
|
startIcon={<ArrowBackIcon />}
|
||||||
sx={{
|
sx={{ mb: 4, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', color: 'text.secondary', borderColor: 'divider' }}
|
||||||
mb: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
textTransform: 'none',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'text.secondary',
|
|
||||||
borderColor: 'divider',
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
color: 'primary.main',
|
|
||||||
bgcolor: 'transparent'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
{t('common.back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* Modern Profile Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3, flexWrap: 'wrap' }}>
|
<Card elevation={0} sx={{
|
||||||
<Avatar src={data.avatar || undefined} sx={{ width: 100, height: 100, border: '4px solid', borderColor: 'primary.main', fontSize: '2.5rem' }}>
|
borderRadius: 4,
|
||||||
{!data.avatar && data.username.charAt(0).toUpperCase()}
|
border: '1px solid',
|
||||||
</Avatar>
|
borderColor: 'divider',
|
||||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1, flexWrap: 'wrap' }}>
|
backdropFilter: 'blur(12px)',
|
||||||
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
|
mb: 4,
|
||||||
<Stack direction="row" spacing={1}>
|
overflow: 'hidden'
|
||||||
{currentUser?.username !== data.username && (
|
}}>
|
||||||
<Button
|
<Box sx={{ height: 120, bgcolor: alpha(theme.palette.primary.main, 0.1) }} />
|
||||||
|
<CardContent sx={{ pt: 0, px: { xs: 2, md: 4 }, pb: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'center', sm: 'flex-end' }, gap: 3, mt: -6 }}>
|
||||||
|
<Avatar
|
||||||
|
src={data.avatar || undefined}
|
||||||
|
sx={{
|
||||||
|
width: 120, height: 120,
|
||||||
|
border: '6px solid',
|
||||||
|
borderColor: (theme) => theme.palette.background.paper,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||||
|
fontSize: '3rem',
|
||||||
|
bgcolor: 'primary.main'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!data.avatar && data.username.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' }, mb: 1 }}>
|
||||||
|
<Typography variant="h3" sx={{ fontWeight: '900', letterSpacing: -0.5 }}>{data.username}</Typography>
|
||||||
|
<Stack direction="row" spacing={3} sx={{ mt: 1, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1 }}>{data.ratings.length}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Rated</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1 }}>{data.followers_count}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Followers</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1 }}>{data.following_count}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}>Following</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ width: { xs: '100%', sm: 'auto' }, mb: 1 }}>
|
||||||
|
{currentUser?.username !== data.username && (
|
||||||
|
<Button
|
||||||
variant={data.is_following ? "outlined" : "contained"}
|
variant={data.is_following ? "outlined" : "contained"}
|
||||||
onClick={handleFollowToggle}
|
onClick={handleFollowToggle}
|
||||||
sx={{ borderRadius: 4, px: 4 }}
|
sx={{ borderRadius: 3, px: 4, fontWeight: 'bold', py: 1 }}
|
||||||
>
|
>
|
||||||
{data.is_following ? "Following" : "Follow"}
|
{data.is_following ? "Following" : "Follow"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentUser?.is_superuser && (
|
{(currentUser?.is_superuser || (adminMode && currentUser?.is_superuser)) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onClick={() => navigate(`/admin-panel/user/${data.id}`)}
|
onClick={() => navigate(`/admin-panel/user/${data.id}`)}
|
||||||
sx={{ borderRadius: 4, px: 4 }}
|
sx={{ borderRadius: 3, px: 3, fontWeight: 'bold', py: 1 }}
|
||||||
>
|
>
|
||||||
{t('admin.manageUser')}
|
{t('admin.manageUser')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6" color="text.secondary">
|
</CardContent>
|
||||||
Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following
|
</Card>
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Category Tabs */}
|
{/* Profile Navigation Tabs */}
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
|
<Paper elevation={0} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider', mb: 4, overflow: 'hidden' }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={(_, v) => setActiveTab(v)}
|
onChange={(_, v) => setActiveTab(v)}
|
||||||
variant="scrollable"
|
variant="fullWidth"
|
||||||
scrollButtons="auto"
|
sx={{
|
||||||
|
bgcolor: alpha(theme.palette.background.paper, 0.4),
|
||||||
|
'& .MuiTab-root': { fontWeight: 'bold', py: 2 }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{categories.map((cat) => (
|
<Tab label="Ratings" />
|
||||||
<Tab key={cat} label={cat} sx={{ fontWeight: 'bold' }} />
|
<Tab label="Guestbook" />
|
||||||
))}
|
<Tab label="Followers" />
|
||||||
|
<Tab label="Following" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Paper>
|
||||||
|
|
||||||
{/* Tiered Content */}
|
{/* Content Area */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<Box sx={{ minHeight: 400 }}>
|
||||||
{tiers.map(tier => {
|
{/* TAB 0: RATINGS */}
|
||||||
const items = currentRatings.filter((r: Rating) => {
|
{activeTab === 0 && (
|
||||||
const s = r.score;
|
<Box>
|
||||||
if (tier.key === 'S') return s >= 9;
|
<Box sx={{ mb: 4, display: 'flex', overflowX: 'auto', pb: 1, gap: 1, '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||||
if (tier.key === 'A') return s >= 7 && s < 9;
|
{ratingCategories.map((cat, idx) => (
|
||||||
if (tier.key === 'B') return s >= 5 && s < 7;
|
<Chip
|
||||||
if (tier.key === 'C') return s >= 3 && s < 5;
|
key={cat} label={cat}
|
||||||
return s < 3;
|
onClick={() => setCategoryTab(idx)}
|
||||||
});
|
color={categoryTab === idx ? 'primary' : 'default'}
|
||||||
|
variant={categoryTab === idx ? 'filled' : 'outlined'}
|
||||||
if (items.length === 0) return null;
|
sx={{ fontWeight: 'bold', px: 1 }}
|
||||||
|
/>
|
||||||
return (
|
))}
|
||||||
<Box key={tier.key}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
|
|
||||||
<Paper sx={{
|
|
||||||
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
bgcolor: getTier(tier.min).color, color: 'black', fontWeight: '900', fontSize: '1.5rem',
|
|
||||||
borderRadius: 2
|
|
||||||
}}>
|
|
||||||
{tier.key}
|
|
||||||
</Paper>
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{tier.title}</Typography>
|
|
||||||
<Divider sx={{ flexGrow: 1 }} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: {
|
|
||||||
xs: 'repeat(2, 1fr)',
|
|
||||||
sm: 'repeat(3, 1fr)',
|
|
||||||
md: 'repeat(4, 1fr)',
|
|
||||||
lg: 'repeat(6, 1fr)',
|
|
||||||
xl: 'repeat(8, 1fr)'
|
|
||||||
},
|
|
||||||
gap: 2
|
|
||||||
}}>
|
|
||||||
{items.map((rating: Rating) => (
|
|
||||||
<Tooltip key={rating.id} title={rating.comment ? `"${rating.comment}"` : rating.flavor_name} arrow>
|
|
||||||
<Card sx={{ transition: 'all 0.3s ease', '&:hover': { transform: 'scale(1.05)', boxShadow: 6, zIndex: 1 } }}>
|
|
||||||
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
||||||
<Box sx={{ position: 'relative', aspectRatio: '1/1', overflow: 'hidden' }}>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src={rating.flavor_image || undefined}
|
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
|
||||||
bgcolor: 'rgba(0,0,0,0.7)', color: 'white', p: 0.5, textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}>
|
|
||||||
<Typography variant="caption" sx={{
|
|
||||||
fontWeight: 'bold', display: '-webkit-box', WebkitLineClamp: 1,
|
|
||||||
WebkitBoxOrient: 'vertical', overflow: 'hidden', fontSize: '0.75rem'
|
|
||||||
}}>
|
|
||||||
{rating.flavor_name}
|
|
||||||
</Typography>
|
|
||||||
{adminMode && (
|
|
||||||
<Button
|
|
||||||
size="small" fullWidth variant="text" color="secondary"
|
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); navigate(`/admin-panel/rating/${rating.id}`); }}
|
|
||||||
sx={{ fontSize: '0.6rem', py: 0, textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Link>
|
|
||||||
</Card>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
|
||||||
})}
|
<Stack spacing={6}>
|
||||||
|
{tiers.map(tier => {
|
||||||
|
const items = currentRatings.filter((r: Rating) => {
|
||||||
|
const s = r.score;
|
||||||
|
if (tier.key === 'S') return s >= 9;
|
||||||
|
if (tier.key === 'A') return s >= 7 && s < 9;
|
||||||
|
if (tier.key === 'B') return s >= 5 && s < 7;
|
||||||
|
if (tier.key === 'C') return s >= 3 && s < 5;
|
||||||
|
return s < 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={tier.key}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
|
||||||
|
<Paper sx={{
|
||||||
|
width: 44, height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
bgcolor: tier.color, color: 'black', fontWeight: '900', fontSize: '1.25rem',
|
||||||
|
borderRadius: 2, boxShadow: `0 4px 12px ${alpha(tier.color, 0.4)}`
|
||||||
|
}}>
|
||||||
|
{tier.key}
|
||||||
|
</Paper>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: '900' }}>{tier.title}</Typography>
|
||||||
|
<Divider sx={{ flexGrow: 1, borderStyle: 'dashed' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{items.map((rating: Rating) => (
|
||||||
|
<Grid key={rating.id} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
|
||||||
|
<Card sx={{
|
||||||
|
height: '100%', borderRadius: 3, border: '1px solid', borderColor: 'divider',
|
||||||
|
transition: 'all 0.2s ease', '&:hover': { transform: 'translateY(-4px)', borderColor: 'primary.main', boxShadow: 4 }
|
||||||
|
}}>
|
||||||
|
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<Box sx={{ position: 'relative', aspectRatio: '1/1', bgcolor: 'action.hover', display: 'flex', alignItems: 'center', justifyContent: 'center', p: 2 }}>
|
||||||
|
<Box component="img" src={rating.flavor_image || undefined} sx={{ height: '80%', width: '80%', objectFit: 'contain', filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.1))' }} />
|
||||||
|
<Box sx={{ position: 'absolute', top: 12, right: 12 }}>
|
||||||
|
<RatingBadge score={rating.score} size="small" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<CardContent sx={{ p: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{rating.flavor_name}
|
||||||
|
</Typography>
|
||||||
|
{rating.comment && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', fontStyle: 'italic', minHeight: 32 }}>
|
||||||
|
"<RichText text={rating.comment} />"
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
{adminMode && (
|
||||||
|
<Box sx={{ px: 2, pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
size="small" fullWidth variant="outlined" color="secondary"
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); navigate(`/admin-panel/rating/${rating.id}`); }}
|
||||||
|
sx={{ borderRadius: 2, textTransform: 'none', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
Manage Rating
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 1: GUESTBOOK */}
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<Box maxWidth="md" sx={{ mx: 'auto' }}>
|
||||||
|
{currentUser && (
|
||||||
|
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px dashed', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>Leave a message</Typography>
|
||||||
|
<form onSubmit={handleAddComment}>
|
||||||
|
<TextField
|
||||||
|
fullWidth multiline rows={3} placeholder="Write something nice..."
|
||||||
|
value={newComment} onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" type="submit" endIcon={<SendIcon />} sx={{ borderRadius: 2 }}>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{data.comments.length === 0 ? (
|
||||||
|
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
|
||||||
|
<Typography color="text.secondary">No messages yet. Be the first!</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
data.comments.map(comment => (
|
||||||
|
<Card key={comment.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<CardContent sx={{ p: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Link to={`/profile/${comment.author_username}`} style={{ textDecoration: 'none' }}>
|
||||||
|
<Avatar src={comment.author_avatar || undefined} sx={{ width: 40, height: 40 }}>
|
||||||
|
{comment.author_username.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
<Box>
|
||||||
|
<Link to={`/profile/${comment.author_username}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{comment.author_username}</Typography>
|
||||||
|
</Link>
|
||||||
|
<Typography variant="caption" color="text.secondary">{formatDate(comment.created_at)}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
{(currentUser?.username === comment.author_username || currentUser?.username === data.username || currentUser?.is_superuser) && (
|
||||||
|
<IconButton size="small" color="error" onClick={() => handleDeleteComment(comment.id)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ mt: 2, pl: 7 }}>
|
||||||
|
<RichText text={comment.text} />
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: FOLLOWERS */}
|
||||||
|
{activeTab === 2 && (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{data.followers.length === 0 ? (
|
||||||
|
<Grid size={12}><Typography sx={{ py: 4, textAlign: 'center' }} color="text.secondary">No followers yet.</Typography></Grid>
|
||||||
|
) : (
|
||||||
|
data.followers.map(follower => (
|
||||||
|
<Grid key={follower.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card component={Link} to={`/profile/${follower.username}`} sx={{ textDecoration: 'none', borderRadius: 3, border: '1px solid', borderColor: 'divider', '&:hover': { borderColor: 'primary.main' } }}>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Avatar src={follower.avatar || undefined}>{follower.username.charAt(0)}</Avatar>
|
||||||
|
<Typography sx={{ fontWeight: 'bold', color: 'text.primary' }}>{follower.username}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: FOLLOWING */}
|
||||||
|
{activeTab === 3 && (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{data.following.length === 0 ? (
|
||||||
|
<Grid size={12}><Typography sx={{ py: 4, textAlign: 'center' }} color="text.secondary">Not following anyone yet.</Typography></Grid>
|
||||||
|
) : (
|
||||||
|
data.following.map(user => (
|
||||||
|
<Grid key={user.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card component={Link} to={`/profile/${user.username}`} sx={{ textDecoration: 'none', borderRadius: 3, border: '1px solid', borderColor: 'divider', '&:hover': { borderColor: 'primary.main' } }}>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Avatar src={user.avatar || undefined}>{user.username.charAt(0)}</Avatar>
|
||||||
|
<Typography sx={{ fontWeight: 'bold', color: 'text.primary' }}>{user.username}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user