mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
redesigned dashboard
This commit is contained in:
1 parent
ff8f5fc182
commit
f3dc60ced8
4 files changed
+375
-234
No files matched your search
@@ -108,13 +108,14 @@ class FlavorSerializer(serializers.ModelSerializer):
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
category_slug = serializers.CharField(source='category.slug', read_only=True)
|
||||
average_rating = serializers.FloatField(read_only=True)
|
||||
followed_average_rating = serializers.FloatField(read_only=True)
|
||||
user_rating = serializers.SerializerMethodField()
|
||||
ratings = RatingSerializer(many=True, read_only=True)
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
if obj.image:
|
||||
|
||||
@@ -508,13 +508,19 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
def dashboard(self, request):
|
||||
user = request.user
|
||||
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) \
|
||||
.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({
|
||||
'user': UserSerializer(user).data,
|
||||
|
||||
+14
-2
@@ -32,7 +32,13 @@ const resources = {
|
||||
"missing": "Missing",
|
||||
"noRatings": "You haven't rated any flavors yet.",
|
||||
"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": {
|
||||
"subtitle": "Manage your account and preferences",
|
||||
@@ -163,7 +169,13 @@ const resources = {
|
||||
"missing": "Fehlend",
|
||||
"noRatings": "Du hast noch keine Sorten bewertet.",
|
||||
"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": {
|
||||
"subtitle": "Verwalte dein Konto und deine Einstellungen",
|
||||
|
||||
+350
-228
@@ -1,25 +1,39 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Avatar,
|
||||
Tabs,
|
||||
Tab,
|
||||
IconButton,
|
||||
Grid,
|
||||
alpha,
|
||||
useTheme,
|
||||
Divider,
|
||||
Stack,
|
||||
Chip,
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
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 api from '../api';
|
||||
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 {
|
||||
user: { username: string, avatar: string | null };
|
||||
@@ -32,258 +46,366 @@ interface DashboardData {
|
||||
const Dashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
useTitle(t('nav.dashboard'));
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 copyToClipboard = () => {
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
alert(t('dashboard.copySuccess'));
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
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(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.get('users/dashboard/');
|
||||
setData(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
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 (!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 (
|
||||
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, 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'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</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>
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Action Bar */}
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', color: 'text.secondary', borderColor: 'divider' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCopyLink}
|
||||
startIcon={<ShareIcon />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{t('dashboard.shareProfile')}
|
||||
</Button>
|
||||
</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 }}>
|
||||
<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.missing')} (${data.missing_count})`} sx={{ fontWeight: 'bold' }} />
|
||||
<Tab label={t('dashboard.myReviews')} sx={{ fontWeight: '900', textTransform: 'none', fontSize: '1.1rem' }} />
|
||||
<Tab label={t('dashboard.exploreNew')} sx={{ fontWeight: '900', textTransform: 'none', fontSize: '1.1rem' }} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{activeTab === 0 ? (
|
||||
<Box>
|
||||
{Object.keys(ratedGrouped).length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography color="text.secondary">{t('dashboard.noRatings')}</Typography>
|
||||
<Button component={Link} to="/" sx={{ mt: 2 }}>{t('dashboard.exploreFlavors')}</Button>
|
||||
</Box>
|
||||
) : (
|
||||
Object.entries(ratedGrouped).map(([category, items]: [string, any]) => (
|
||||
<Box key={category} sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>{category}</Typography>
|
||||
<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: any) => (
|
||||
<Tooltip key={rating.id} title={`${rating.flavor_name} - ${rating.score}/10`} arrow>
|
||||
<Card sx={{
|
||||
position: 'relative',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
|
||||
}}>
|
||||
<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.6)',
|
||||
color: 'white',
|
||||
p: 0.5,
|
||||
textAlign: 'center',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{Object.keys(missingGrouped).length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h5" color="primary">{t('dashboard.allRated')}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
Object.entries(missingGrouped).map(([category, flavors]: [string, any]) => (
|
||||
<Box key={category} sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>{category}</Typography>
|
||||
<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
|
||||
}}>
|
||||
{flavors.map((flavor: any) => (
|
||||
<Tooltip key={flavor.id} title={`${flavor.name} - Community Avg: ${(flavor.average_rating || 0).toFixed(1)}/10`} arrow>
|
||||
<Card key={flavor.id} sx={{
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
|
||||
}}>
|
||||
<Link to={`/flavor/${flavor.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Box sx={{ position: 'relative', aspectRatio: '1/1', overflow: 'hidden', borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url || 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.5)',
|
||||
color: 'white',
|
||||
p: 0.5,
|
||||
textAlign: 'center',
|
||||
backdropFilter: 'blur(2px)'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{(flavor.average_rating || 0).toFixed(1)}/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'
|
||||
}}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
{/* TAB 0: MY REVIEWS */}
|
||||
{activeTab === 0 && (
|
||||
<Stack spacing={3}>
|
||||
{data.my_ratings.length === 0 ? (
|
||||
<Box sx={{ py: 10, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" color="text.secondary">{t('dashboard.noRatings')}</Typography>
|
||||
<Button onClick={() => setActiveTab(1)} sx={{ mt: 2 }}>{t('dashboard.exploreFlavors')}</Button>
|
||||
</Box>
|
||||
) : (
|
||||
data.my_ratings.map(rating => (
|
||||
<Card key={rating.id} elevation={0} sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
|
||||
transition: 'border-color 0.2s',
|
||||
'&:hover': { borderColor: 'primary.main' }
|
||||
}}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('dashboard.editingReview', { flavor: rating.flavor_name })}</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">{t('dashboard.newScore')}</Typography>
|
||||
<TextField
|
||||
type="number" size="small"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={editScore} onChange={(e) => setEditScore(Number(e.target.value))}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
multiline rows={3} value={editComment}
|
||||
onChange={setEditComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setEditingRatingId(null)}>{t('common.cancel')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Avatar src={rating.flavor_image || undefined} variant="rounded" sx={{ width: 60, height: 60, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', p: 0.5 }}>
|
||||
F
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
|
||||
<Link to={`/flavor/${rating.flavor}`} style={{ color: 'inherit', textDecoration: 'none' }}>{rating.flavor_name}</Link>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} • {formatDate(rating.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<RatingBadge score={rating.score} size="large" />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: alpha('#000', 0.02), borderRadius: 2 }}>
|
||||
{rating.comment ? (
|
||||
<RichText text={rating.comment} />
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>{t('dashboard.noRatings')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CommentIcon fontSize="small" />}
|
||||
onClick={() => setExpandedReplies(prev => ({ ...prev, [rating.id]: !prev[rating.id] }))}
|
||||
sx={{ textTransform: 'none', fontWeight: 'bold', color: 'text.secondary' }}
|
||||
>
|
||||
{rating.replies.length} {t('common.replies')}
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<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>
|
||||
</Stack>
|
||||
|
||||
{/* Replies Inline */}
|
||||
<Collapse in={expandedReplies[rating.id]}>
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box key={reply.id} sx={{ mb: 2, pl: 2, borderLeft: '3px solid', borderColor: 'primary.main' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}>
|
||||
{reply.user}
|
||||
{reply.user === data.user.username && <VerifiedIcon sx={{ fontSize: '0.8rem', color: 'primary.main', ml: 0.5 }} />}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="caption" color="text.secondary">{formatDate(reply.created_at)}</Typography>
|
||||
{reply.user === data.user.username && (
|
||||
<Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>{t('common.delete')}</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography variant="body2"><RichText text={reply.text} /></Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||
<MentionTextField
|
||||
placeholder={t('community.writeReply')}
|
||||
value={replyInputs[rating.id] || ''}
|
||||
onChange={val => setReplyInputs({ ...replyInputs, [rating.id]: val })}
|
||||
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={() => handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]} sx={{ height: 40, px: 3 }}>{t('common.reply')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* TAB 1: EXPLORE NEW */}
|
||||
{activeTab === 1 && (
|
||||
<Box>
|
||||
{/* Filter Controls */}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 4 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<TextField
|
||||
fullWidth placeholder={t('dashboard.searchMissing')}
|
||||
value={exploreSearch} onChange={e => setExploreSearch(e.target.value)}
|
||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment> } }}
|
||||
sx={{ bgcolor: 'background.paper', borderRadius: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ overflowX: 'auto', pb: 1, '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||
{categories.map(cat => (
|
||||
<Chip
|
||||
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 })}
|
||||
onClick={() => setExploreCategory(cat)}
|
||||
color={exploreCategory === cat ? 'primary' : 'default'}
|
||||
variant={exploreCategory === cat ? 'filled' : 'outlined'}
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user