From a6e946444a8da5645167c86bacb93bca96d5d14d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 18 Feb 2026 04:36:19 +0100 Subject: [PATCH] redesign taste profile --- backend/api/migrations/0017_profilecomment.py | 28 + backend/api/models.py | 9 + backend/api/serializers.py | 17 + backend/api/views.py | 50 +- frontend/src/pages/PublicProfile.tsx | 528 ++++++++++++------ 5 files changed, 450 insertions(+), 182 deletions(-) create mode 100644 backend/api/migrations/0017_profilecomment.py diff --git a/backend/api/migrations/0017_profilecomment.py b/backend/api/migrations/0017_profilecomment.py new file mode 100644 index 0000000..0af9071 --- /dev/null +++ b/backend/api/migrations/0017_profilecomment.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.2 on 2026-02-18 03:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0016_notification_ticket_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProfileComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authored_profile_comments', to=settings.AUTH_USER_MODEL)), + ('profile_owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_comments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 4118fbe..1e4ad90 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -176,3 +176,12 @@ class TicketMessage(models.Model): class Meta: ordering = ['created_at'] + +class ProfileComment(models.Model): + profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE) + author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE) + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 188df41..9b4947e 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -136,6 +136,23 @@ class TicketSerializer(serializers.ModelSerializer): return obj.user.avatar.url return None +class ProfileCommentSerializer(serializers.ModelSerializer): + author_username = serializers.CharField(source='author.username', read_only=True) + author_avatar = serializers.SerializerMethodField() + + class Meta: + from .models import ProfileComment + model = ProfileComment + fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at'] + + def get_author_avatar(self, obj): + if obj.author.avatar: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.author.avatar.url) + return obj.author.avatar.url + return None + class AdminUserListSerializer(serializers.ModelSerializer): ips = serializers.SerializerMethodField() diff --git a/backend/api/views.py b/backend/api/views.py index 6fe01c4..3938633 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -8,8 +8,13 @@ from django_filters.rest_framework import DjangoFilterBackend from django_ratelimit.decorators import ratelimit from django.utils.decorators import method_decorator from rest_framework.pagination import PageNumberPagination -from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP -from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer, TicketSerializer, TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer +from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment +from .serializers import ( + UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, + ReplySerializer, NotificationSerializer, TicketSerializer, + TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer, + ProfileCommentSerializer +) def log_user_ip(user, request): x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') @@ -345,18 +350,53 @@ class UserViewSet(viewsets.ModelViewSet): return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score') + comments = user.profile_comments.select_related('author').order_by('-created_at') + + followers = user.followers.all() + following = user.following.all() return Response({ 'id': user.id, 'username': user.username, 'theme': user.theme, 'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None, - 'following_count': user.following.count(), - 'followers_count': user.followers.count(), + 'following_count': following.count(), + 'followers_count': followers.count(), 'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False, - 'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data + 'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data, + 'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data, + 'followers': UserSerializer(followers, many=True, context={'request': request}).data, + 'following': UserSerializer(following, many=True, context={'request': request}).data }) + @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) + def add_comment(self, request, pk=None): + profile_owner = self.get_object() + text = request.data.get('text') + if not text: + return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST) + + comment = ProfileComment.objects.create( + profile_owner=profile_owner, + author=request.user, + text=text + ) + return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['delete'], url_path='delete_comment/(?P[^/.]+)', permission_classes=[permissions.IsAuthenticated]) + def delete_comment(self, request, pk=None, comment_id=None): + profile_owner = self.get_object() + try: + comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner) + except ProfileComment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user: + return Response(status=status.HTTP_403_FORBIDDEN) + + comment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=False, methods=['get']) def me(self, request): log_user_ip(request.user, request) diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index e30aa7c..b65a2ed 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { Typography, Box, @@ -8,19 +8,28 @@ import { Avatar, Container, CircularProgress, - Tooltip, Tabs, Tab, Paper, Divider, Button, - Stack + Stack, + alpha, + useTheme, + Grid, + TextField, + IconButton, + Chip } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Link } from 'react-router-dom'; +import SendIcon from '@mui/icons-material/Send'; +import DeleteIcon from '@mui/icons-material/Delete'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; import { useTranslation } from 'react-i18next'; +import RatingBadge from '../components/RatingBadge'; +import RichText from '../components/RichText'; +import { formatDate } from '../utils/date'; interface Rating { id: number; @@ -33,6 +42,20 @@ interface Rating { created_at: string; } +interface ProfileComment { + id: number; + author_username: string; + author_avatar: string | null; + text: string; + created_at: string; +} + +interface MiniUser { + id: number; + username: string; + avatar: string | null; +} + interface ProfileData { id: number; username: string; @@ -41,8 +64,10 @@ interface ProfileData { following_count: number; followers_count: number; is_following: boolean; - is_superuser?: boolean; ratings: Rating[]; + comments: ProfileComment[]; + followers: MiniUser[]; + following: MiniUser[]; } interface PublicProfileProps { @@ -51,36 +76,34 @@ interface PublicProfileProps { const PublicProfile: React.FC = ({ adminMode }) => { const { t } = useTranslation(); + const theme = useTheme(); const { username } = useParams<{ username: string }>(); const navigate = useNavigate(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState(0); // 0: Ratings, 1: Guestbook, 2: Followers, 3: Following + const [categoryTab, setCategoryTab] = useState(0); const [currentUser, setCurrentUser] = useState<{username: string, is_superuser: boolean} | null>(null); + + // Comment state + const [newComment, setNewComment] = useState(''); - const handleGoBack = () => { - if (window.history.length > 1) { - navigate(-1); - } else { - navigate('/'); - } + const fetchProfile = async () => { + try { + const [profileRes, meRes] = await Promise.all([ + api.get(`users/profile/${username}/`), + localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null }) + ]); + setData(profileRes.data); + if (meRes.data) setCurrentUser(meRes.data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } }; useEffect(() => { - const fetchProfile = async () => { - try { - const [profileRes, meRes] = await Promise.all([ - api.get(`users/profile/${username}/`), - localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null }) - ]); - setData(profileRes.data); - if (meRes.data) setCurrentUser(meRes.data); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; fetchProfile(); }, [username]); @@ -99,12 +122,34 @@ const PublicProfile: React.FC = ({ adminMode }) => { } }; + const handleAddComment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newComment.trim() || !data) return; + try { + await api.post(`users/${data.id}/add_comment/`, { text: newComment }); + setNewComment(''); + fetchProfile(); + } catch (err) { + alert('Failed to add comment'); + } + }; + + const handleDeleteComment = async (commentId: number) => { + if (!data || !confirm('Delete this comment?')) return; + try { + await api.delete(`users/${data.id}/delete_comment/${commentId}/`); + fetchProfile(); + } catch (err) { + alert('Failed to delete comment'); + } + }; + useTitle(data ? `${data.username}'s Ratings` : 'Profile'); if (loading) return ; if (!data) return Profile not found.; - // Group by category + // Rating filtering const categoryGroups = data.ratings.reduce((acc: any, rating) => { const cat = rating.category_name || 'Other'; if (!acc[cat]) acc[cat] = []; @@ -112,181 +157,310 @@ const PublicProfile: React.FC = ({ adminMode }) => { return acc; }, {}); - const categories = ['All', ...Object.keys(categoryGroups)]; - const currentRatings = activeTab === 0 ? data.ratings : categoryGroups[categories[activeTab]]; - - // Tier logic - const getTier = (score: number) => { - if (score >= 9) return { label: 'S', color: '#ff7f7f', desc: 'Elite' }; - if (score >= 7) return { label: 'A', color: '#ffbf7f', desc: 'Great' }; - if (score >= 5) return { label: 'B', color: '#ffff7f', desc: 'Good' }; - if (score >= 3) return { label: 'C', color: '#bfff7f', desc: 'Okay' }; - return { label: 'D', color: '#7fff7f', desc: 'Avoid' }; - }; + const ratingCategories = ['All', ...Object.keys(categoryGroups)]; + const currentRatings = categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]]; const tiers = [ - { key: 'S', title: 'S-Tier', min: 9 }, - { key: 'A', title: 'A-Tier', min: 7 }, - { key: 'B', title: 'B-Tier', min: 5 }, - { key: 'C', title: 'C-Tier', min: 3 }, - { key: 'D', title: 'D-Tier', min: 0 }, + { key: 'S', title: 'S-Tier', min: 9, color: '#ff7f7f' }, + { key: 'A', title: 'A-Tier', min: 7, color: '#ffbf7f' }, + { key: 'B', title: 'B-Tier', min: 5, color: '#ffff7f' }, + { key: 'C', title: 'C-Tier', min: 3, color: '#bfff7f' }, + { key: 'D', title: 'D-Tier', min: 0, color: '#7fff7f' }, ]; + const handleGoBack = () => { + if (window.history.length > 1) navigate(-1); + else navigate('/'); + }; + return ( - + - {/* Profile Header */} - - - {!data.avatar && data.username.charAt(0).toUpperCase()} - - - - {data.username} - - {currentUser?.username !== data.username && ( - - )} - {currentUser?.is_superuser && ( - + )} + {(currentUser?.is_superuser || (adminMode && currentUser?.is_superuser)) && ( + - )} - - - - Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following - - - + sx={{ borderRadius: 3, px: 3, fontWeight: 'bold', py: 1 }} + > + {t('admin.manageUser')} + + )} + + + + - {/* Category Tabs */} - + {/* Profile Navigation Tabs */} + setActiveTab(v)} - variant="scrollable" - scrollButtons="auto" + variant="fullWidth" + sx={{ + bgcolor: alpha(theme.palette.background.paper, 0.4), + '& .MuiTab-root': { fontWeight: 'bold', py: 2 } + }} > - {categories.map((cat) => ( - - ))} + + + + - + - {/* Tiered Content */} - - {tiers.map(tier => { - const items = currentRatings.filter((r: Rating) => { - const s = r.score; - if (tier.key === 'S') return s >= 9; - if (tier.key === 'A') return s >= 7 && s < 9; - if (tier.key === 'B') return s >= 5 && s < 7; - if (tier.key === 'C') return s >= 3 && s < 5; - return s < 3; - }); - - if (items.length === 0) return null; - - return ( - - - - {tier.key} - - {tier.title} - - - - - {items.map((rating: Rating) => ( - - - - - - - {rating.score}/10 - - - - - {rating.flavor_name} - - {adminMode && ( - - )} - - - - - ))} - + {/* Content Area */} + + {/* TAB 0: RATINGS */} + {activeTab === 0 && ( + + + {ratingCategories.map((cat, idx) => ( + setCategoryTab(idx)} + color={categoryTab === idx ? 'primary' : 'default'} + variant={categoryTab === idx ? 'filled' : 'outlined'} + sx={{ fontWeight: 'bold', px: 1 }} + /> + ))} - ); - })} + + + {tiers.map(tier => { + const items = currentRatings.filter((r: Rating) => { + const s = r.score; + if (tier.key === 'S') return s >= 9; + if (tier.key === 'A') return s >= 7 && s < 9; + if (tier.key === 'B') return s >= 5 && s < 7; + if (tier.key === 'C') return s >= 3 && s < 5; + return s < 3; + }); + + if (items.length === 0) return null; + + return ( + + + + {tier.key} + + {tier.title} + + + + + {items.map((rating: Rating) => ( + + + + + + + + + + + + {rating.flavor_name} + + {rating.comment && ( + + "" + + )} + + + {adminMode && ( + + + + )} + + + ))} + + + ); + })} + + + )} + + {/* TAB 1: GUESTBOOK */} + {activeTab === 1 && ( + + {currentUser && ( + + Leave a message +
+ setNewComment(e.target.value)} + sx={{ mb: 2 }} + /> + + +
+ )} + + + {data.comments.length === 0 ? ( + + No messages yet. Be the first! + + ) : ( + data.comments.map(comment => ( + + + + + + + {comment.author_username.charAt(0).toUpperCase()} + + + + + {comment.author_username} + + {formatDate(comment.created_at)} + + + {(currentUser?.username === comment.author_username || currentUser?.username === data.username || currentUser?.is_superuser) && ( + handleDeleteComment(comment.id)}> + + + )} + + + + + + + )) + )} + +
+ )} + + {/* TAB 2: FOLLOWERS */} + {activeTab === 2 && ( + + {data.followers.length === 0 ? ( + No followers yet. + ) : ( + data.followers.map(follower => ( + + + + {follower.username.charAt(0)} + {follower.username} + + + + )) + )} + + )} + + {/* TAB 3: FOLLOWING */} + {activeTab === 3 && ( + + {data.following.length === 0 ? ( + Not following anyone yet. + ) : ( + data.following.map(user => ( + + + + {user.username.charAt(0)} + {user.username} + + + + )) + )} + + )}
);