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:
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 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):
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.utils.decorators import method_decorator
from rest_framework.pagination import PageNumberPagination
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer, TicketSerializer, TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer
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,
ProfileCommentSerializer
)
def log_user_ip(user, request):
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)
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({
'id': user.id,
'username': user.username,
'theme': user.theme,
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
'following_count': user.following.count(),
'followers_count': user.followers.count(),
'following_count': following.count(),
'followers_count': followers.count(),
'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'])
def me(self, request):
log_user_ip(request.user, request)
+351 -177
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
Typography,
Box,
@@ -8,19 +8,28 @@ import {
Avatar,
Container,
CircularProgress,
Tooltip,
Tabs,
Tab,
Paper,
Divider,
Button,
Stack
Stack,
alpha,
useTheme,
Grid,
TextField,
IconButton,
Chip
} from '@mui/material';
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 { useTitle } from '../hooks/useTitle';
import { useTranslation } from 'react-i18next';
import RatingBadge from '../components/RatingBadge';
import RichText from '../components/RichText';
import { formatDate } from '../utils/date';
interface Rating {
id: number;
@@ -33,6 +42,20 @@ interface Rating {
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 {
id: number;
username: string;
@@ -41,8 +64,10 @@ interface ProfileData {
following_count: number;
followers_count: number;
is_following: boolean;
is_superuser?: boolean;
ratings: Rating[];
comments: ProfileComment[];
followers: MiniUser[];
following: MiniUser[];
}
interface PublicProfileProps {
@@ -51,36 +76,34 @@ interface PublicProfileProps {
const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
const { t } = useTranslation();
const theme = useTheme();
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
const [data, setData] = useState<ProfileData | null>(null);
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);
// Comment state
const [newComment, setNewComment] = useState('');
const handleGoBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
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);
}
};
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();
}, [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');
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
if (!data) return <Typography>Profile not found.</Typography>;
// Group by category
// Rating filtering
const categoryGroups = data.ratings.reduce((acc: any, rating) => {
const cat = rating.category_name || 'Other';
if (!acc[cat]) acc[cat] = [];
@@ -112,181 +157,310 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
return acc;
}, {});
const categories = ['All', ...Object.keys(categoryGroups)];
const currentRatings = activeTab === 0 ? data.ratings : categoryGroups[categories[activeTab]];
// 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 ratingCategories = ['All', ...Object.keys(categoryGroups)];
const currentRatings = categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]];
const tiers = [
{ key: 'S', title: 'S-Tier', min: 9 },
{ key: 'A', title: 'A-Tier', min: 7 },
{ key: 'B', title: 'B-Tier', min: 5 },
{ key: 'C', title: 'C-Tier', min: 3 },
{ key: 'D', title: 'D-Tier', min: 0 },
{ key: 'S', title: 'S-Tier', min: 9, color: '#ff7f7f' },
{ key: 'A', title: 'A-Tier', min: 7, color: '#ffbf7f' },
{ key: 'B', title: 'B-Tier', min: 5, color: '#ffff7f' },
{ key: 'C', title: 'C-Tier', min: 3, color: '#bfff7f' },
{ key: 'D', title: 'D-Tier', min: 0, color: '#7fff7f' },
];
const handleGoBack = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
return (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{
mb: 4,
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
color: 'text.secondary',
borderColor: 'divider',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
bgcolor: 'transparent'
}
}}
sx={{ mb: 4, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', color: 'text.secondary', borderColor: 'divider' }}
>
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
{t('common.back')}
</Button>
{/* Profile Header */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3, flexWrap: 'wrap' }}>
<Avatar src={data.avatar || undefined} sx={{ width: 100, height: 100, border: '4px solid', borderColor: 'primary.main', fontSize: '2.5rem' }}>
{!data.avatar && data.username.charAt(0).toUpperCase()}
</Avatar>
<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>
<Stack direction="row" spacing={1}>
{currentUser?.username !== data.username && (
<Button
{/* Modern Profile Header */}
<Card elevation={0} sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
backdropFilter: 'blur(12px)',
mb: 4,
overflow: 'hidden'
}}>
<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"}
onClick={handleFollowToggle}
sx={{ borderRadius: 4, px: 4 }}
>
{data.is_following ? "Following" : "Follow"}
</Button>
)}
{currentUser?.is_superuser && (
<Button
sx={{ borderRadius: 3, px: 4, fontWeight: 'bold', py: 1 }}
>
{data.is_following ? "Following" : "Follow"}
</Button>
)}
{(currentUser?.is_superuser || (adminMode && currentUser?.is_superuser)) && (
<Button
variant="outlined"
color="secondary"
onClick={() => navigate(`/admin-panel/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
</Typography>
</Box>
</Box>
sx={{ borderRadius: 3, px: 3, fontWeight: 'bold', py: 1 }}
>
{t('admin.manageUser')}
</Button>
)}
</Stack>
</Box>
</CardContent>
</Card>
{/* Category Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
{/* Profile Navigation Tabs */}
<Paper elevation={0} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider', mb: 4, overflow: 'hidden' }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
variant="scrollable"
scrollButtons="auto"
variant="fullWidth"
sx={{
bgcolor: alpha(theme.palette.background.paper, 0.4),
'& .MuiTab-root': { fontWeight: 'bold', py: 2 }
}}
>
{categories.map((cat) => (
<Tab key={cat} label={cat} sx={{ fontWeight: 'bold' }} />
))}
<Tab label="Ratings" />
<Tab label="Guestbook" />
<Tab label="Followers" />
<Tab label="Following" />
</Tabs>
</Box>
</Paper>
{/* Tiered Content */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 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: 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>
{/* Content Area */}
<Box sx={{ minHeight: 400 }}>
{/* TAB 0: RATINGS */}
{activeTab === 0 && (
<Box>
<Box sx={{ mb: 4, display: 'flex', overflowX: 'auto', pb: 1, gap: 1, '&::-webkit-scrollbar': { display: 'none' } }}>
{ratingCategories.map((cat, idx) => (
<Chip
key={cat} label={cat}
onClick={() => setCategoryTab(idx)}
color={categoryTab === idx ? 'primary' : 'default'}
variant={categoryTab === idx ? 'filled' : 'outlined'}
sx={{ fontWeight: 'bold', px: 1 }}
/>
))}
</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>
</Container>
);