implemented community feature

This commit is contained in:
nvrl committed 2026-02-16 19:44:00 +01:00
1 parent 2fcf10137f
commit fbdd9f3d17
9 files changed
+286 -27

No files matched your search

@@ -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),
),
]
+1
View File
@@ -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)
+12 -2
View File
@@ -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:
+29 -7
View File
@@ -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
+16
View File
@@ -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 = () => {
<ListItemText primary="Home" />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/community" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Community" />
</ListItemButton>
</ListItem>
<ListItemButton onClick={() => setCatOpen(!catOpen)}>
<ListItemText primary="Categories" />
@@ -294,6 +301,14 @@ const App: React.FC = () => {
</Typography>
<Box sx={{ display: { xs: 'none', md: 'flex' }, ml: 4 }}>
<Button
color="inherit"
component={Link}
to="/community"
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }}
>
Community
</Button>
<Button
color="inherit"
endIcon={<ArrowDropDownIcon />}
@@ -383,6 +398,7 @@ const App: React.FC = () => {
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/community" element={<CommunityFeed />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/login" element={<Login />} />
+12 -8
View File
@@ -283,12 +283,12 @@ const CategoryList: React.FC = () => {
<Typography variant="body2" sx={{ fontStyle: 'italic', mb: 1, fontSize: { xs: '0.9rem', md: '1.1rem' }, display: '-webkit-box', overflow: 'hidden', WebkitBoxOrient: 'vertical', WebkitLineClamp: 3 }}>
"{featuredReview.comment}"
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar src={featuredReview.user_avatar || undefined} sx={{ width: 20, height: 24, fontSize: '0.6rem' }}>
<Link to={`/profile/${featuredReview.user}`} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar src={featuredReview.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.6rem', '&:hover': { opacity: 0.8 } }}>
{!featuredReview.user_avatar && featuredReview.user.charAt(0)}
</Avatar>
<Typography variant="caption" color="text.secondary">— {featuredReview.user}</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ '&:hover': { color: 'primary.main' } }}>— {featuredReview.user}</Typography>
</Link>
</Box>
)}
@@ -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' } }}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar src={review.user_avatar || undefined} sx={{ width: 32, height: 32, mr: 1 }}>
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
</Avatar>
<Link to={`/profile/${review.user}`} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center' }}>
<Avatar src={review.user_avatar || undefined} sx={{ width: 32, height: 32, mr: 1, '&:hover': { opacity: 0.8 } }}>
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
</Avatar>
</Link>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{review.user}</Typography>
<Link to={`/profile/${review.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', '&:hover': { color: 'primary.main' } }}>{review.user}</Typography>
</Link>
<Typography variant="caption" color="text.secondary">
on <Link to={`/flavor/${review.flavor}`} style={{ color: 'inherit' }}>{review.flavor_name}</Link>
</Typography>
+148
View File
@@ -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<FeedRating[]>([]);
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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Community Feed</Typography>
{ratings.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center', borderRadius: 4 }}>
<Typography variant="h6" gutterBottom>Your feed is quiet...</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Follow other Holy Energy fans to see their latest flavor ratings and reviews here!
</Typography>
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
Find flavors and reviewers
</Button>
</Paper>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{ratings.map(rating => (
<Card key={rating.id} sx={{ borderRadius: 3, transition: 'transform 0.2s', '&:hover': { transform: 'translateY(-4px)' } }}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', alignItems: 'center' }}>
<Avatar src={rating.user_avatar || undefined} sx={{ width: 40, height: 40, mr: 2 }}>
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', '&:hover': { color: 'primary.main' } }}>
{rating.user}
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(rating.created_at).toLocaleDateString()}
</Typography>
</Box>
</Link>
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', sm: 'row' } }}>
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none' }}>
<Box sx={{ width: { xs: '100%', sm: 120 }, aspectRatio: '1/1', borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
<Box
component="img"
src={rating.flavor_image || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Link>
<Box sx={{ flex: 1 }}>
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 0.5, '&:hover': { color: 'primary.main' } }}>
{rating.flavor_name}
</Typography>
</Link>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
{rating.category_name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
<MuiRating value={rating.score} readOnly max={10} size="small" />
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
</Box>
{rating.comment && (
<Typography variant="body2" sx={{
fontStyle: 'italic',
color: 'text.secondary',
borderLeft: '3px solid',
borderColor: 'divider',
pl: 2,
py: 0.5
}}>
"{rating.comment}"
</Typography>
)}
</Box>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Container>
);
};
// Internal helper for empty state
const Paper = ({ children, sx }: any) => (
<Box sx={{ bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', ...sx }}>
{children}
</Box>
);
export default CommunityFeed;
+41 -6
View File
@@ -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<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0);
const [currentUser, setCurrentUser] = useState<string | null>(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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
@@ -104,14 +128,25 @@ const PublicProfile: React.FC = () => {
</Button>
{/* Profile Header */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 6, gap: 3, flexWrap: 'wrap' }}>
<Avatar src={data.avatar || undefined} sx={{ width: 100, height: 100, border: '4px solid', borderColor: 'primary.main', fontSize: '2.5rem' }}>
{!data.avatar && data.username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
<Box sx={{ flex: 1, minWidth: 200 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1, flexWrap: 'wrap' }}>
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
{currentUser !== data.username && (
<Button
variant={data.is_following ? "outlined" : "contained"}
onClick={handleFollowToggle}
sx={{ borderRadius: 4, px: 4 }}
>
{data.is_following ? "Following" : "Follow"}
</Button>
)}
</Box>
<Typography variant="h6" color="text.secondary">
Taste Profile • {data.ratings.length} Flavors Rated
Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following
</Typography>
</Box>
</Box>
+8 -4
View File
@@ -280,12 +280,16 @@ const FlavorDetail: React.FC = () => {
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 2 }}>
<Avatar src={rating.user_avatar || undefined} sx={{ width: 40, height: 40, mr: 2, mt: 0.5 }}>
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
</Avatar>
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Avatar src={rating.user_avatar || undefined} sx={{ width: 40, height: 40, mr: 2, mt: 0.5, '&:hover': { opacity: 0.8 } }}>
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
</Avatar>
</Link>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5, flexWrap: 'wrap', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{rating.user}</Typography>
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', '&:hover': { color: 'primary.main' } }}>{rating.user}</Typography>
</Link>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MuiRating value={rating.score} readOnly size="small" max={10} />
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>