diff --git a/backend/api/migrations/0012_user_following.py b/backend/api/migrations/0012_user_following.py new file mode 100644 index 0000000..ea506b2 --- /dev/null +++ b/backend/api/migrations/0012_user_following.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.2 on 2026-02-16 18:43 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_alter_flavor_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='following', + field=models.ManyToManyField(blank=True, related_name='followers', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index fc1e82f..168e8e8 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -23,6 +23,7 @@ class User(AbstractUser): avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) pending_email = models.EmailField(max_length=254, blank=True, null=True) email_confirmation_code = models.CharField(max_length=6, blank=True, null=True) + following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True) def save(self, *args, **kwargs): super().save(*args, **kwargs) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index a8d7534..878680f 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -3,10 +3,13 @@ from .models import User, Flavor, Category, Rating, Reply class UserSerializer(serializers.ModelSerializer): avatar = serializers.SerializerMethodField() + following_count = serializers.IntegerField(source='following.count', read_only=True) + followers_count = serializers.IntegerField(source='followers.count', read_only=True) + is_following = serializers.SerializerMethodField() class Meta: model = User - fields = ['id', 'username', 'email', 'theme', 'avatar'] + fields = ['id', 'username', 'email', 'theme', 'avatar', 'following_count', 'followers_count', 'is_following'] def get_avatar(self, obj): if obj.avatar: @@ -16,6 +19,12 @@ class UserSerializer(serializers.ModelSerializer): return obj.avatar.url return None + def get_is_following(self, obj): + request = self.context.get('request') + if request and request.user.is_authenticated: + return request.user.following.filter(pk=obj.pk).exists() + return False + class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category @@ -31,6 +40,7 @@ class ReplySerializer(serializers.ModelSerializer): class RatingSerializer(serializers.ModelSerializer): user = serializers.StringRelatedField(read_only=True) + user_id = serializers.IntegerField(source='user.id', read_only=True) user_avatar = serializers.SerializerMethodField() flavor_name = serializers.CharField(source='flavor.name', read_only=True) flavor_image = serializers.SerializerMethodField() @@ -40,7 +50,7 @@ class RatingSerializer(serializers.ModelSerializer): class Meta: model = Rating - fields = ['id', 'user', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'score', 'comment', 'created_at', 'replies'] + fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'score', 'comment', 'created_at', 'replies'] def get_user_avatar(self, obj): if obj.user.avatar: diff --git a/backend/api/views.py b/backend/api/views.py index 668ca75..c6d1729 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -62,13 +62,21 @@ class RatingViewSet(viewsets.ModelViewSet): serializer.save(user=self.request.user) def get_queryset(self): - if self.action in ['list', 'retrieve']: - return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user') - # Allow users to see only their own ratings when editing/deleting? - # No, for detail views they need to find it. - # The permission class IsOwnerOrReadOnly (custom) would be better, - # but standard ViewSet logic handles "get_object" which we can protect. - return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user') + return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at') + + @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) + def feed(self, request): + # Get ratings from people the user follows + followed_users = request.user.following.all() + feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at') + + page = self.paginate_queryset(feed_ratings) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(feed_ratings, many=True) + return Response(serializer.data) def check_object_permissions(self, request, obj): super().check_object_permissions(request, obj) @@ -254,6 +262,20 @@ class UserViewSet(viewsets.ModelViewSet): data['message'] = message return Response(data) + @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) + def follow(self, request, pk=None): + user_to_follow = self.get_object() + if user_to_follow == request.user: + return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST) + request.user.following.add(user_to_follow) + return Response({'status': 'followed'}) + + @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) + def unfollow(self, request, pk=None): + user_to_unfollow = self.get_object() + request.user.following.remove(user_to_unfollow) + return Response({'status': 'unfollowed'}) + @action(detail=False, methods=['post']) def confirm_email(self, request): user = request.user diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 046db02..b4264c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors')) const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail')); const Dashboard = lazy(() => import('./pages/Dashboard')); const PublicProfile = lazy(() => import('./pages/PublicProfile')); +const CommunityFeed = lazy(() => import('./pages/CommunityFeed')); const Login = lazy(() => import('./pages/Login')); const Settings = lazy(() => import('./pages/Settings')); @@ -221,6 +222,12 @@ const App: React.FC = () => { + + + setDrawerOpen(false)}> + + + setCatOpen(!catOpen)}> @@ -294,6 +301,14 @@ const App: React.FC = () => { + + + ) : ( + + {ratings.map(rating => ( + + + + + + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} + + + + {rating.user} + + + {new Date(rating.created_at).toLocaleDateString()} + + + + + + + + + + + + + + + + + {rating.flavor_name} + + + + {rating.category_name} + + + + + {rating.score}/10 + + + {rating.comment && ( + + "{rating.comment}" + + )} + + + + + ))} + + )} + + ); +}; + +// Internal helper for empty state +const Paper = ({ children, sx }: any) => ( + + {children} + +); + +export default CommunityFeed; diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index b99e1bc..fcadccd 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -32,9 +32,13 @@ interface Rating { } interface ProfileData { + id: number; username: string; theme: string; avatar: string | null; + following_count: number; + followers_count: number; + is_following: boolean; ratings: Rating[]; } @@ -43,12 +47,17 @@ const PublicProfile: React.FC = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState(0); + const [currentUser, setCurrentUser] = useState(null); useEffect(() => { const fetchProfile = async () => { try { - const res = await api.get(`users/profile/${username}/`); - setData(res.data); + 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.username); } catch (err) { console.error(err); } finally { @@ -58,6 +67,21 @@ const PublicProfile: React.FC = () => { fetchProfile(); }, [username]); + const handleFollowToggle = async () => { + if (!data) return; + try { + if (data.is_following) { + await api.post(`users/${data.id}/unfollow/`); + setData({ ...data, is_following: false, followers_count: data.followers_count - 1 }); + } else { + await api.post(`users/${data.id}/follow/`); + setData({ ...data, is_following: true, followers_count: data.followers_count + 1 }); + } + } catch (err) { + alert('Failed to update follow status. Please login.'); + } + }; + useTitle(data ? `${data.username}'s Ratings` : 'Profile'); if (loading) return ; @@ -104,14 +128,25 @@ const PublicProfile: React.FC = () => { {/* Profile Header */} - + {!data.avatar && data.username.charAt(0).toUpperCase()} - - {data.username} + + + {data.username} + {currentUser !== data.username && ( + + )} + - Taste Profile • {data.ratings.length} Flavors Rated + Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index f948d5e..1f05d7d 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -280,12 +280,16 @@ const FlavorDetail: React.FC = () => { ) : ( <> - - {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} - + + + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} + + - {rating.user} + + {rating.user} + {rating.score}/10