diff --git a/backend/api/views.py b/backend/api/views.py
index 54a0eb9..ebce8eb 100644
--- a/backend/api/views.py
+++ b/backend/api/views.py
@@ -6,9 +6,13 @@ from django.db.models import Avg
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
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer
+class FeedPagination(PageNumberPagination):
+ page_size = 10
+
class FlavorViewSet(viewsets.ModelViewSet):
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
serializer_class = FlavorSerializer
@@ -77,10 +81,11 @@ class RatingViewSet(viewsets.ModelViewSet):
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)
+ paginator = FeedPagination()
+ page = paginator.paginate_queryset(feed_ratings, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
- return self.get_paginated_response(serializer.data)
+ return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(feed_ratings, many=True)
return Response(serializer.data)
@@ -116,6 +121,12 @@ class UserViewSet(viewsets.ModelViewSet):
return [permissions.AllowAny()]
return super().get_permissions()
+ @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
+ def following_list(self, request):
+ following = request.user.following.all()
+ serializer = self.get_serializer(following, many=True)
+ return Response(serializer.data)
+
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
def signup(self, request):
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e24da42..bc33842 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -212,7 +212,10 @@ const App: React.FC = () => {
{user?.username ? user.username.charAt(0).toUpperCase() : '?'}
- setDrawerOpen(false)} style={{ color: 'inherit', textDecoration: 'none' }}>Holy Flavors
+ setDrawerOpen(false)} style={{ color: 'inherit', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '8px' }}>
+
+ Holy Flavors Archive
+
@@ -294,9 +297,9 @@ const App: React.FC = () => {
}}>
-
- Holy Flavors
- Holy
+
+
+ Holy Flavors Archive
diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx
index 559c654..0d663d2 100644
--- a/frontend/src/pages/CommunityFeed.tsx
+++ b/frontend/src/pages/CommunityFeed.tsx
@@ -9,7 +9,13 @@ import {
CircularProgress,
Divider,
Rating as MuiRating,
- Button
+ Button,
+ Grid,
+ List,
+ ListItem,
+ ListItemAvatar,
+ ListItemText,
+ ListItemButton
} from '@mui/material';
import StarIcon from '@mui/icons-material/Star';
import { Link } from 'react-router-dom';
@@ -31,126 +37,206 @@ interface FeedRating {
created_at: string;
}
+interface FollowedUser {
+ id: number;
+ username: string;
+ avatar: string | null;
+}
+
const CommunityFeed: React.FC = () => {
useTitle('Community Feed');
const [ratings, setRatings] = useState([]);
+ const [following, setFollowing] = useState([]);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [nextPage, setNextPage] = useState(null);
+
+ const fetchInitialData = async () => {
+ setLoading(true);
+ try {
+ const [feedRes, followingRes] = await Promise.all([
+ api.get('ratings/feed/'),
+ api.get('users/following_list/')
+ ]);
+
+ const feedData = feedRes.data.results || feedRes.data;
+ setRatings(Array.isArray(feedData) ? feedData : []);
+ setNextPage(feedRes.data.next || null);
+ setFollowing(followingRes.data);
+ } catch (err) {
+ console.error('Failed to fetch initial feed data', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleLoadMore = async () => {
+ if (!nextPage || loadingMore) return;
+ setLoadingMore(true);
+ try {
+ // nextPage is a full URL from DRF, we can use axios directly or parse it
+ const res = await api.get(nextPage);
+ const newData = res.data.results || res.data;
+ setRatings(prev => [...prev, ...(Array.isArray(newData) ? newData : [])]);
+ setNextPage(res.data.next || null);
+ } catch (err) {
+ console.error('Failed to load more feed items', err);
+ } finally {
+ setLoadingMore(false);
+ }
+ };
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();
+ fetchInitialData();
}, []);
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()}
-
-
-
+
+ {/* Main Feed */}
+
+ {ratings.length === 0 ? (
+
+ Your feed is quiet...
+
+ Follow other Holy Energy fans to see their latest flavor ratings and reviews here!
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- {rating.flavor_name}
-
+ ) : (
+
+ {ratings.map(rating => (
+
+
+
+
+
+ {!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
+
+
+
+ {rating.user}
+
+
+ {new Date(rating.created_at).toLocaleDateString()}
+
+
-
- {rating.category_name}
-
-
-
- {/* Compact rating for mobile, stars for desktop */}
-
-
- {rating.score}/10
-
-
-
- {rating.score}/10
-
- {rating.comment && (
-
- "{rating.comment}"
-
- )}
+
+
+
+
+
+
+
+
+
+
+
+ {rating.flavor_name}
+
+
+
+ {rating.category_name}
+
+
+
+ {/* Compact rating for mobile, stars for desktop */}
+
+
+ {rating.score}/10
+
+
+
+ {rating.score}/10
+
+
+
+ {rating.comment && (
+
+ "{rating.comment}"
+
+ )}
+
+
+
+
+ ))}
+
+ {nextPage && (
+
+
+ )}
-
-
- ))}
-
- )}
+ )}
+
+
+ {/* Sidebar */}
+
+
+
+
+ Following ({following.length})
+
+ {following.length === 0 ? (
+
+ You aren't following anyone yet.
+
+ ) : (
+
+ {following.map((user) => (
+
+
+
+
+
+ {!user.avatar && user.username.charAt(0).toUpperCase()}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
);
};
-// Internal helper for empty state
-const Paper = ({ children, sx }: any) => (
-
- {children}
-
-);
-
export default CommunityFeed;