mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 17:07:01 +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_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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user