redesigned dashboard

This commit is contained in:
nvrl committed 2026-02-17 20:10:56 +01:00
1 parent ff8f5fc182
commit f3dc60ced8
4 files changed
+375 -234

No files matched your search

+2 -1
View File
@@ -108,13 +108,14 @@ class FlavorSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True) category_name = serializers.CharField(source='category.name', read_only=True)
category_slug = serializers.CharField(source='category.slug', read_only=True) category_slug = serializers.CharField(source='category.slug', read_only=True)
average_rating = serializers.FloatField(read_only=True) average_rating = serializers.FloatField(read_only=True)
followed_average_rating = serializers.FloatField(read_only=True)
user_rating = serializers.SerializerMethodField() user_rating = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True) ratings = RatingSerializer(many=True, read_only=True)
image_url = serializers.SerializerMethodField() image_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Flavor model = Flavor
fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'user_rating', 'ratings', 'image_url', 'is_available', 'is_legacy', 'shop_url'] fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'followed_average_rating', 'user_rating', 'ratings', 'image_url', 'is_available', 'is_legacy', 'shop_url']
def get_image_url(self, obj): def get_image_url(self, obj):
if obj.image: if obj.image:
+9 -3
View File
@@ -508,13 +508,19 @@ class UserViewSet(viewsets.ModelViewSet):
def dashboard(self, request): def dashboard(self, request):
user = request.user user = request.user
rated_ids = user.ratings.values_list('flavor_id', flat=True) rated_ids = user.ratings.values_list('flavor_id', flat=True)
followed_users = user.following.all()
# Annotate with community average rating so it's available for sorting/display # Missing flavors with community avg and followed circle avg
missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \ missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \
.select_related('category') \ .select_related('category') \
.annotate(average_rating=Avg('ratings__score')) .annotate(
average_rating=Avg('ratings__score'),
followed_average_rating=Avg('ratings__score', filter=models.Q(ratings__user__in=followed_users))
).order_by('category__name', 'name')
rated_flavors = user.ratings.select_related('flavor', 'flavor__category') rated_flavors = user.ratings.select_related('flavor', 'flavor__category') \
.prefetch_related('replies', 'replies__user') \
.order_by('-created_at')
return Response({ return Response({
'user': UserSerializer(user).data, 'user': UserSerializer(user).data,
+14 -2
View File
@@ -32,7 +32,13 @@ const resources = {
"missing": "Missing", "missing": "Missing",
"noRatings": "You haven't rated any flavors yet.", "noRatings": "You haven't rated any flavors yet.",
"exploreFlavors": "Go explore flavors", "exploreFlavors": "Go explore flavors",
"allRated": "Amazing! You've rated everything! 🏆" "allRated": "Amazing! You've rated everything! 🏆",
"myReviews": "My Reviews",
"exploreNew": "Explore New",
"editingReview": "Editing review for {{flavor}}",
"newScore": "New Score:",
"searchMissing": "Search flavors you haven't rated...",
"circleAvg": "Average rating from people you follow"
}, },
"settings": { "settings": {
"subtitle": "Manage your account and preferences", "subtitle": "Manage your account and preferences",
@@ -163,7 +169,13 @@ const resources = {
"missing": "Fehlend", "missing": "Fehlend",
"noRatings": "Du hast noch keine Sorten bewertet.", "noRatings": "Du hast noch keine Sorten bewertet.",
"exploreFlavors": "Sorten entdecken", "exploreFlavors": "Sorten entdecken",
"allRated": "Wahnsinn! Du hast alles bewertet! 🏆" "allRated": "Wahnsinn! Du hast alles bewertet! 🏆",
"myReviews": "Meine Rezensionen",
"exploreNew": "Neu entdecken",
"editingReview": "Rezension bearbeiten für {{flavor}}",
"newScore": "Neue Bewertung:",
"searchMissing": "Suche nach Sorten, die du noch nicht bewertet hast...",
"circleAvg": "Durchschnittsbewertung der Personen, denen du folgst"
}, },
"settings": { "settings": {
"subtitle": "Verwalte dein Konto und deine Einstellungen", "subtitle": "Verwalte dein Konto und deine Einstellungen",
+350 -228
View File
@@ -1,25 +1,39 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { import {
Typography, Typography,
Card, Card,
CardContent, CardContent,
Box, Box,
Button, Button,
TextField,
Container, Container,
CircularProgress, CircularProgress,
Avatar, Avatar,
Tabs, Tabs,
Tab, Tab,
IconButton, Grid,
alpha,
useTheme,
Divider,
Stack,
Chip,
Collapse,
TextField,
InputAdornment,
Tooltip Tooltip
} from '@mui/material'; } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Link, useNavigate } from 'react-router-dom'; import ShareIcon from '@mui/icons-material/Share';
import CommentIcon from '@mui/icons-material/Comment';
import VerifiedIcon from '@mui/icons-material/Verified';
import SearchIcon from '@mui/icons-material/Search';
import { useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import api from '../api'; import api from '../api';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import RatingBadge from '../components/RatingBadge';
import MentionTextField from '../components/MentionTextField';
import RichText from '../components/RichText';
import { formatDate } from '../utils/date';
interface DashboardData { interface DashboardData {
user: { username: string, avatar: string | null }; user: { username: string, avatar: string | null };
@@ -32,258 +46,366 @@ interface DashboardData {
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
useTitle(t('nav.dashboard')); useTitle(t('nav.dashboard'));
const [data, setData] = useState<DashboardData | null>(null); const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
// Filtering states
const [exploreCategory, setExploreCategory] = useState<string>('All');
const [exploreSearch, setExploreSearch] = useState('');
// Inline Review Edit state
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState('');
// Inline Reply state
const [replyInputs, setReplyInputs] = useState<{[key: number]: string}>({});
const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({});
const shareUrl = data ? `${window.location.origin}/profile/${data.user.username}` : ''; const shareUrl = data ? `${window.location.origin}/profile/${data.user.username}` : '';
const copyToClipboard = () => { const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl); navigator.clipboard.writeText(shareUrl);
alert(t('dashboard.copySuccess')); alert(t('dashboard.copySuccess'));
}; };
const handleGoBack = () => { const handleGoBack = () => {
if (window.history.length > 1) { if (window.history.length > 1) navigate(-1);
navigate(-1); else navigate('/');
} else { };
navigate('/');
} const fetchData = async () => {
try {
const res = await api.get('users/dashboard/');
setData(res.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}; };
useEffect(() => { useEffect(() => {
const fetchData = async () => {
try {
const res = await api.get('users/dashboard/');
setData(res.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchData(); fetchData();
}, []); }, []);
// Update Review Logic
const handleUpdateRating = async (ratingId: number) => {
try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
setEditingRatingId(null);
fetchData();
} catch (err) { alert('Failed to update review'); }
};
const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this review?')) return;
try {
await api.delete(`ratings/${ratingId}/`);
fetchData();
} catch (err) { alert('Failed to delete review'); }
};
// Reply Logic
const handleReplySubmit = async (ratingId: number) => {
const text = replyInputs[ratingId];
if (!text) return;
try {
await api.post(`ratings/${ratingId}/reply/`, { text });
setReplyInputs({ ...replyInputs, [ratingId]: '' });
setExpandedReplies({ ...expandedReplies, [ratingId]: true });
fetchData();
} catch (err) { alert('Failed to send reply'); }
};
const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return;
try {
await api.delete(`replies/${replyId}/`);
fetchData();
} catch (err) { alert('Failed to delete reply'); }
};
// Performance Optimization: Memoize grouped missing flavors
const categories = useMemo(() => {
if (!data) return ['All'];
const cats = new Set(data.missing_flavors.map(f => f.category_name));
return ['All', ...Array.from(cats).sort()];
}, [data]);
const filteredMissing = useMemo(() => {
if (!data) return [];
return data.missing_flavors.filter(f => {
const matchCat = exploreCategory === 'All' || f.category_name === exploreCategory;
const matchSearch = f.name.toLowerCase().includes(exploreSearch.toLowerCase());
return matchCat && matchSearch;
});
}, [data, exploreCategory, exploreSearch]);
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>Please login to view dashboard.</Typography>; if (!data) return <Typography>Please login to view dashboard.</Typography>;
const groupAndSort = (array: any[], sortKey: string) => {
const grouped = array.reduce((result, currentValue) => {
const groupKey = currentValue.category_name || currentValue.flavor?.category_name || 'Other';
(result[groupKey] = result[groupKey] || []).push(currentValue);
return result;
}, {});
// Sort items within each group
Object.keys(grouped).forEach(key => {
grouped[key].sort((a: any, b: any) => (b[sortKey] || 0) - (a[sortKey] || 0));
});
return grouped;
};
const ratedGrouped = groupAndSort(data.my_ratings, 'score');
const missingGrouped = groupAndSort(data.missing_flavors, 'average_rating');
return ( return (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}> <Container maxWidth="lg" sx={{ py: 4 }}>
<Button {/* Action Bar */}
variant="outlined" <Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
onClick={handleGoBack} <Button
startIcon={<ArrowBackIcon />} variant="outlined"
sx={{ onClick={handleGoBack}
mb: 4, startIcon={<ArrowBackIcon />}
borderRadius: 2, sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', color: 'text.secondary', borderColor: 'divider' }}
textTransform: 'none', >
fontWeight: 'bold', {window.history.length > 1 ? t('common.back') : t('common.backToHome')}
color: 'text.secondary', </Button>
borderColor: 'divider',
'&:hover': { <Button
borderColor: 'primary.main', variant="contained"
color: 'primary.main', onClick={handleCopyLink}
bgcolor: 'transparent' startIcon={<ShareIcon />}
} sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
}} >
> {t('dashboard.shareProfile')}
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} </Button>
</Button>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 6, flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar src={data.user.avatar || undefined} sx={{ width: 64, height: 64, border: '3px solid', borderColor: 'primary.main' }}>
{!data.user.avatar && data.user.username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{t('nav.dashboard')}</Typography>
<Typography variant="body2" color="text.secondary">{t('dashboard.welcome', { username: data.user.username })}</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', p: 1, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ ml: 1, mr: 2, display: { xs: 'none', md: 'block' } }}>{t('dashboard.shareProfile')}</Typography>
<TextField
size="small"
variant="standard"
value={shareUrl}
slotProps={{ input: { readOnly: true, disableUnderline: true } }}
sx={{ width: { xs: 150, sm: 250 }, px: 1 }}
/>
<IconButton size="small" onClick={copyToClipboard} color="primary">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Box>
</Box> </Box>
{/* Header Profile Info */}
<Card elevation={0} sx={{
borderRadius: 4,
mb: 4,
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
backdropFilter: 'blur(12px)',
border: '1px solid',
borderColor: 'divider'
}}>
<CardContent sx={{ p: 4 }}>
<Grid container spacing={3} alignItems="center">
<Grid size={{ xs: 12, sm: 'auto' }}>
<Avatar src={data.user.avatar || undefined} sx={{ width: 100, height: 100, border: '4px solid', borderColor: 'primary.main' }}>
{!data.user.avatar && data.user.username.charAt(0).toUpperCase()}
</Avatar>
</Grid>
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
<Typography variant="h3" sx={{ fontWeight: '900', mb: 0.5 }}>{data.user.username}</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
{t('dashboard.welcome', { username: data.user.username })}
</Typography>
<Stack direction="row" spacing={3} sx={{ mt: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: '900', color: 'primary.main' }}>{data.rated_count}</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1 }}>{t('dashboard.myRatings')}</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Box>
<Typography variant="h5" sx={{ fontWeight: '900' }}>{data.missing_count}</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1 }}>{t('dashboard.missing')}</Typography>
</Box>
</Stack>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Tabs Section */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} textColor="primary" indicatorColor="primary"> <Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} textColor="primary" indicatorColor="primary">
<Tab label={`${t('dashboard.myRatings')} (${data.rated_count})`} sx={{ fontWeight: 'bold' }} /> <Tab label={t('dashboard.myReviews')} sx={{ fontWeight: '900', textTransform: 'none', fontSize: '1.1rem' }} />
<Tab label={`${t('dashboard.missing')} (${data.missing_count})`} sx={{ fontWeight: 'bold' }} /> <Tab label={t('dashboard.exploreNew')} sx={{ fontWeight: '900', textTransform: 'none', fontSize: '1.1rem' }} />
</Tabs> </Tabs>
</Box> </Box>
{activeTab === 0 ? ( {/* TAB 0: MY REVIEWS */}
<Box> {activeTab === 0 && (
{Object.keys(ratedGrouped).length === 0 ? ( <Stack spacing={3}>
<Box sx={{ textAlign: 'center', py: 8 }}> {data.my_ratings.length === 0 ? (
<Typography color="text.secondary">{t('dashboard.noRatings')}</Typography> <Box sx={{ py: 10, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4, border: '1px dashed', borderColor: 'divider' }}>
<Button component={Link} to="/" sx={{ mt: 2 }}>{t('dashboard.exploreFlavors')}</Button> <Typography variant="h6" color="text.secondary">{t('dashboard.noRatings')}</Typography>
</Box> <Button onClick={() => setActiveTab(1)} sx={{ mt: 2 }}>{t('dashboard.exploreFlavors')}</Button>
) : ( </Box>
Object.entries(ratedGrouped).map(([category, items]: [string, any]) => ( ) : (
<Box key={category} sx={{ mb: 6 }}> data.my_ratings.map(rating => (
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>{category}</Typography> <Card key={rating.id} elevation={0} sx={{
<Box sx={{ borderRadius: 4,
display: 'grid', border: '1px solid',
gridTemplateColumns: { borderColor: 'divider',
xs: 'repeat(2, 1fr)', bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
sm: 'repeat(3, 1fr)', transition: 'border-color 0.2s',
md: 'repeat(4, 1fr)', '&:hover': { borderColor: 'primary.main' }
lg: 'repeat(6, 1fr)', }}>
xl: 'repeat(8, 1fr)' <CardContent sx={{ p: 3 }}>
}, {editingRatingId === rating.id ? (
gap: 2 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
}}> <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('dashboard.editingReview', { flavor: rating.flavor_name })}</Typography>
{items.map((rating: any) => ( <Stack direction="row" spacing={2} alignItems="center">
<Tooltip key={rating.id} title={`${rating.flavor_name} - ${rating.score}/10`} arrow> <Typography variant="body2">{t('dashboard.newScore')}</Typography>
<Card sx={{ <TextField
position: 'relative', type="number" size="small"
transition: 'all 0.3s ease', slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 } value={editScore} onChange={(e) => setEditScore(Number(e.target.value))}
}}> sx={{ width: 80 }}
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none', color: 'inherit' }}> />
<Box sx={{ position: 'relative', aspectRatio: '1/1', overflow: 'hidden' }}> </Stack>
<Box <MentionTextField
component="img" multiline rows={3} value={editComment}
src={rating.flavor_image || undefined} onChange={setEditComment}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
loading="lazy" <Stack direction="row" spacing={1}>
/> <Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
<Box sx={{ <Button variant="outlined" size="small" onClick={() => setEditingRatingId(null)}>{t('common.cancel')}</Button>
position: 'absolute', </Stack>
bottom: 0, </Box>
left: 0, ) : (
right: 0, <>
bgcolor: 'rgba(0,0,0,0.6)', <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
color: 'white', <Box sx={{ display: 'flex', gap: 2 }}>
p: 0.5, <Avatar src={rating.flavor_image || undefined} variant="rounded" sx={{ width: 60, height: 60, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', p: 0.5 }}>
textAlign: 'center', F
backdropFilter: 'blur(4px)' </Avatar>
}}> <Box>
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{rating.score}/10</Typography> <Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
</Box> <Link to={`/flavor/${rating.flavor}`} style={{ color: 'inherit', textDecoration: 'none' }}>{rating.flavor_name}</Link>
</Box> </Typography>
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}> <Typography variant="caption" color="text.secondary" display="block">
<Typography variant="caption" sx={{ {t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} • {formatDate(rating.created_at)}
fontWeight: 'bold', </Typography>
display: '-webkit-box', </Box>
WebkitLineClamp: 1, </Box>
WebkitBoxOrient: 'vertical', <RatingBadge score={rating.score} size="large" />
overflow: 'hidden', </Box>
fontSize: '0.75rem'
}}> <Box sx={{ mb: 2, p: 2, bgcolor: alpha('#000', 0.02), borderRadius: 2 }}>
{rating.flavor_name} {rating.comment ? (
</Typography> <RichText text={rating.comment} />
</CardContent> ) : (
</Link> <Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>{t('dashboard.noRatings')}</Typography>
</Card> )}
</Tooltip> </Box>
))}
</Box> <Stack direction="row" spacing={2} alignItems="center">
</Box> <Button
)) size="small"
)} startIcon={<CommentIcon fontSize="small" />}
</Box> onClick={() => setExpandedReplies(prev => ({ ...prev, [rating.id]: !prev[rating.id] }))}
) : ( sx={{ textTransform: 'none', fontWeight: 'bold', color: 'text.secondary' }}
<Box> >
{Object.keys(missingGrouped).length === 0 ? ( {rating.replies.length} {t('common.replies')}
<Box sx={{ textAlign: 'center', py: 8 }}> </Button>
<Typography variant="h5" color="primary">{t('dashboard.allRated')}</Typography> <Box sx={{ flexGrow: 1 }} />
</Box> <Button size="small" sx={{ fontWeight: 'bold' }} onClick={() => { setEditingRatingId(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); }}>{t('common.edit')}</Button>
) : ( <Button size="small" color="error" sx={{ fontWeight: 'bold' }} onClick={() => handleDeleteRating(rating.id)}>{t('common.delete')}</Button>
Object.entries(missingGrouped).map(([category, flavors]: [string, any]) => ( </Stack>
<Box key={category} sx={{ mb: 6 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>{category}</Typography> {/* Replies Inline */}
<Box sx={{ <Collapse in={expandedReplies[rating.id]}>
display: 'grid', <Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
gridTemplateColumns: { {rating.replies.map((reply: any) => (
xs: 'repeat(2, 1fr)', <Box key={reply.id} sx={{ mb: 2, pl: 2, borderLeft: '3px solid', borderColor: 'primary.main' }}>
sm: 'repeat(3, 1fr)', <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
md: 'repeat(4, 1fr)', <Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}>
lg: 'repeat(6, 1fr)', {reply.user}
xl: 'repeat(8, 1fr)' {reply.user === data.user.username && <VerifiedIcon sx={{ fontSize: '0.8rem', color: 'primary.main', ml: 0.5 }} />}
}, </Typography>
gap: 2 <Stack direction="row" spacing={1} alignItems="center">
}}> <Typography variant="caption" color="text.secondary">{formatDate(reply.created_at)}</Typography>
{flavors.map((flavor: any) => ( {reply.user === data.user.username && (
<Tooltip key={flavor.id} title={`${flavor.name} - Community Avg: ${(flavor.average_rating || 0).toFixed(1)}/10`} arrow> <Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>{t('common.delete')}</Button>
<Card key={flavor.id} sx={{ )}
transition: 'all 0.3s ease', </Stack>
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 } </Box>
}}> <Typography variant="body2"><RichText text={reply.text} /></Typography>
<Link to={`/flavor/${flavor.id}`} style={{ textDecoration: 'none', color: 'inherit' }}> </Box>
<Box sx={{ position: 'relative', aspectRatio: '1/1', overflow: 'hidden', borderBottom: '1px solid', borderColor: 'divider' }}> ))}
<Box <Stack direction="row" spacing={1} sx={{ mt: 2 }}>
component="img" <MentionTextField
src={flavor.image_url || undefined} placeholder={t('community.writeReply')}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} value={replyInputs[rating.id] || ''}
loading="lazy" onChange={val => setReplyInputs({ ...replyInputs, [rating.id]: val })}
/> onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)}
<Box sx={{ />
position: 'absolute', <Button variant="contained" size="small" onClick={() => handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]} sx={{ height: 40, px: 3 }}>{t('common.reply')}</Button>
bottom: 0, </Stack>
left: 0, </Box>
right: 0, </Collapse>
bgcolor: 'rgba(0,0,0,0.5)', </>
color: 'white', )}
p: 0.5, </CardContent>
textAlign: 'center', </Card>
backdropFilter: 'blur(2px)' ))
}}> )}
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{(flavor.average_rating || 0).toFixed(1)}/10</Typography> </Stack>
</Box> )}
</Box>
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}> {/* TAB 1: EXPLORE NEW */}
<Typography variant="caption" sx={{ {activeTab === 1 && (
fontWeight: 'bold', <Box>
display: '-webkit-box', {/* Filter Controls */}
WebkitLineClamp: 1, <Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 4 }}>
WebkitBoxOrient: 'vertical', <Box sx={{ flexGrow: 1 }}>
overflow: 'hidden', <TextField
fontSize: '0.75rem' fullWidth placeholder={t('dashboard.searchMissing')}
}}> value={exploreSearch} onChange={e => setExploreSearch(e.target.value)}
{flavor.name} slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment> } }}
</Typography> sx={{ bgcolor: 'background.paper', borderRadius: 2 }}
</CardContent> />
</Link> </Box>
</Card> <Stack direction="row" spacing={1} sx={{ overflowX: 'auto', pb: 1, '&::-webkit-scrollbar': { display: 'none' } }}>
</Tooltip> {categories.map(cat => (
))} <Chip
</Box> key={cat} label={cat === 'All' ? t('nav.home').replace('Home', 'All') : t(`categories.${data.missing_flavors.find(f => f.category_name === cat)?.category_slug}`, { defaultValue: cat })}
</Box> onClick={() => setExploreCategory(cat)}
)) color={exploreCategory === cat ? 'primary' : 'default'}
)} variant={exploreCategory === cat ? 'filled' : 'outlined'}
</Box> sx={{ fontWeight: 'bold', height: 40, px: 1 }}
/>
))}
</Stack>
</Stack>
{filteredMissing.length === 0 ? (
<Box sx={{ py: 10, textAlign: 'center' }}>
<Typography variant="h5" color="primary" sx={{ fontWeight: '900' }}>{t('dashboard.allRated')}</Typography>
</Box>
) : (
<Grid container spacing={2}>
{filteredMissing.map(flavor => (
<Grid key={flavor.id} size={{ xs: 12, sm: 6, md: 4 }}>
<Card elevation={0} 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/${flavor.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Box sx={{ position: 'relative', aspectRatio: '16/10', bgcolor: 'action.hover', display: 'flex', alignItems: 'center', justifyContent: 'center', p: 2 }}>
<Box component="img" src={flavor.image_url || undefined} sx={{ height: '100%', maxWidth: '100%', objectFit: 'contain', filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.1))' }} />
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
<RatingBadge score={flavor.average_rating || 0} size="small" />
</Box>
{flavor.followed_average_rating && (
<Box sx={{ position: 'absolute', top: 10, right: 10 }}>
<Tooltip title={t('dashboard.circleAvg')}>
<Box sx={{ bgcolor: alpha(theme.palette.secondary.main, 0.9), color: 'white', px: 1, py: 0.2, borderRadius: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VerifiedIcon sx={{ fontSize: '0.8rem' }} />
<Typography variant="caption" sx={{ fontWeight: '900' }}>{flavor.followed_average_rating.toFixed(1)}</Typography>
</Box>
</Tooltip>
</Box>
)}
</Box>
<CardContent sx={{ p: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{flavor.name}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">{t(`categories.${flavor.category_slug}`, { defaultValue: flavor.category_name })}</Typography>
</CardContent>
</Link>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
)} )}
</Container> </Container>
); );