redesign taste profile

This commit is contained in:
nvrl committed 2026-02-18 04:36:19 +01:00
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'],
},
),
]
+9
View File
@@ -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']
+17
View File
@@ -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
View File
@@ -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)
+351 -177
View File
@@ -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>
); );