From f3dc60ced82f3cd8b2bcd43b0052dfd63cd9b428 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Feb 2026 20:10:56 +0100 Subject: [PATCH] redesigned dashboard --- backend/api/serializers.py | 3 +- backend/api/views.py | 12 +- frontend/src/i18n.ts | 16 +- frontend/src/pages/Dashboard.tsx | 578 +++++++++++++++++++------------ 4 files changed, 375 insertions(+), 234 deletions(-) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index b4b3af3..cf05d09 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -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: diff --git a/backend/api/views.py b/backend/api/views.py index b1aaaef..e8ed6e9 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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, diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 3394794..2e7c610 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 86476a4..4e86b2c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState(0); + + // Filtering states + const [exploreCategory, setExploreCategory] = useState('All'); + const [exploreSearch, setExploreSearch] = useState(''); + + // Inline Review Edit state + const [editingRatingId, setEditingRatingId] = useState(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 ; if (!data) return Please login to view dashboard.; - 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 ( - - - - - - - {!data.user.avatar && data.user.username.charAt(0).toUpperCase()} - - - {t('nav.dashboard')} - {t('dashboard.welcome', { username: data.user.username })} - - - - {t('dashboard.shareProfile')} - - - - - + + {/* Action Bar */} + + + + + {/* Header Profile Info */} + alpha(theme.palette.background.paper, 0.6), + backdropFilter: 'blur(12px)', + border: '1px solid', + borderColor: 'divider' + }}> + + + + + {!data.user.avatar && data.user.username.charAt(0).toUpperCase()} + + + + {data.user.username} + + {t('dashboard.welcome', { username: data.user.username })} + + + + + {data.rated_count} + {t('dashboard.myRatings')} + + + + {data.missing_count} + {t('dashboard.missing')} + + + + + + + + {/* Tabs Section */} setActiveTab(v)} textColor="primary" indicatorColor="primary"> - - + + - {activeTab === 0 ? ( - - {Object.keys(ratedGrouped).length === 0 ? ( - - {t('dashboard.noRatings')} - - - ) : ( - Object.entries(ratedGrouped).map(([category, items]: [string, any]) => ( - - {category} - - {items.map((rating: any) => ( - - - - - - - {rating.score}/10 - - - - - {rating.flavor_name} - - - - - - ))} - - - )) - )} - - ) : ( - - {Object.keys(missingGrouped).length === 0 ? ( - - {t('dashboard.allRated')} - - ) : ( - Object.entries(missingGrouped).map(([category, flavors]: [string, any]) => ( - - {category} - - {flavors.map((flavor: any) => ( - - - - - - - {(flavor.average_rating || 0).toFixed(1)}/10 - - - - - {flavor.name} - - - - - - ))} - - - )) - )} - + {/* TAB 0: MY REVIEWS */} + {activeTab === 0 && ( + + {data.my_ratings.length === 0 ? ( + + {t('dashboard.noRatings')} + + + ) : ( + data.my_ratings.map(rating => ( + alpha(theme.palette.background.paper, 0.8), + transition: 'border-color 0.2s', + '&:hover': { borderColor: 'primary.main' } + }}> + + {editingRatingId === rating.id ? ( + + {t('dashboard.editingReview', { flavor: rating.flavor_name })} + + {t('dashboard.newScore')} + setEditScore(Number(e.target.value))} + sx={{ width: 80 }} + /> + + + + + + + + ) : ( + <> + + + + F + + + + {rating.flavor_name} + + + {t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} β€’ {formatDate(rating.created_at)} + + + + + + + + {rating.comment ? ( + + ) : ( + {t('dashboard.noRatings')} + )} + + + + + + + + + + {/* Replies Inline */} + + + {rating.replies.map((reply: any) => ( + + + + {reply.user} + {reply.user === data.user.username && } + + + {formatDate(reply.created_at)} + {reply.user === data.user.username && ( + + )} + + + + + ))} + + setReplyInputs({ ...replyInputs, [rating.id]: val })} + onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)} + /> + + + + + + )} + + + )) + )} + + )} + + {/* TAB 1: EXPLORE NEW */} + {activeTab === 1 && ( + + {/* Filter Controls */} + + + setExploreSearch(e.target.value)} + slotProps={{ input: { startAdornment: } }} + sx={{ bgcolor: 'background.paper', borderRadius: 2 }} + /> + + + {categories.map(cat => ( + 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 }} + /> + ))} + + + + {filteredMissing.length === 0 ? ( + + {t('dashboard.allRated')} + + ) : ( + + {filteredMissing.map(flavor => ( + + + + + + + + + {flavor.followed_average_rating && ( + + + + + {flavor.followed_average_rating.toFixed(1)} + + + + )} + + + + {flavor.name} + + {t(`categories.${flavor.category_slug}`, { defaultValue: flavor.category_name })} + + + + + ))} + + )} + )} );