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 = () => {
+
}
@@ -383,6 +398,7 @@ const App: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/pages/CategoryList.tsx b/frontend/src/pages/CategoryList.tsx
index 1a76061..7be09f2 100644
--- a/frontend/src/pages/CategoryList.tsx
+++ b/frontend/src/pages/CategoryList.tsx
@@ -283,12 +283,12 @@ const CategoryList: React.FC = () => {
"{featuredReview.comment}"
-
-
+
+
{!featuredReview.user_avatar && featuredReview.user.charAt(0)}
- — {featuredReview.user}
-
+ — {featuredReview.user}
+
)}
@@ -360,11 +360,15 @@ const CategoryList: React.FC = () => {
sx={{ p: 3, mb: 3, breakInside: 'avoid', borderRadius: 3, transition: 'border-color 0.2s', '&:hover': { borderColor: 'primary.main' } }}
>
-
- {!review.user_avatar && review.user.charAt(0).toUpperCase()}
-
+
+
+ {!review.user_avatar && review.user.charAt(0).toUpperCase()}
+
+
- {review.user}
+
+ {review.user}
+
on {review.flavor_name}
diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx
new file mode 100644
index 0000000..c42d314
--- /dev/null
+++ b/frontend/src/pages/CommunityFeed.tsx
@@ -0,0 +1,148 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Typography,
+ Box,
+ Card,
+ CardContent,
+ Avatar,
+ Container,
+ CircularProgress,
+ Divider,
+ Rating as MuiRating,
+ Button
+} from '@mui/material';
+import { Link } from 'react-router-dom';
+import api from '../api';
+import { useTitle } from '../hooks/useTitle';
+
+interface FeedRating {
+ id: number;
+ user: string;
+ user_id: number;
+ user_avatar: string | null;
+ flavor: number;
+ flavor_name: string;
+ flavor_image: string | null;
+ category_name: string;
+ category_slug: string;
+ score: number;
+ comment: string;
+ created_at: string;
+}
+
+const CommunityFeed: React.FC = () => {
+ useTitle('Community Feed');
+ const [ratings, setRatings] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchFeed = async () => {
+ try {
+ const res = await api.get('ratings/feed/');
+ // Handle pagination if results is present
+ const data = Array.isArray(res.data) ? res.data : (res.data.results || []);
+ setRatings(data);
+ } catch (err) {
+ console.error('Failed to fetch feed', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchFeed();
+ }, []);
+
+ if (loading) return ;
+
+ return (
+
+ Community Feed
+
+ {ratings.length === 0 ? (
+
+ Your feed is quiet...
+
+ Follow other Holy Energy fans to see their latest flavor ratings and reviews here!
+
+
+
+ ) : (
+
+ {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