mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
added follow list
This commit is contained in:
1 parent
fef7ed82fa
commit
a3c4e1bbc8
2 files changed
+199
-102
No files matched your search
+13
-2
@@ -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):
|
||||
|
||||
@@ -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<FeedRating[]>([]);
|
||||
const [following, setFollowing] = useState<FollowedUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [nextPage, setNextPage] = useState<string | null>(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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Container maxWidth="lg" 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>
|
||||
<Grid container spacing={4}>
|
||||
{/* Main Feed */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
{ratings.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center', borderRadius: 4, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider' }}>
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<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(-2px)' } }}>
|
||||
<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>
|
||||
<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 }}>
|
||||
{/* Compact rating for mobile, stars for desktop */}
|
||||
<Box sx={{ display: { xs: 'flex', sm: 'none' }, alignItems: 'center', gap: 0.5 }}>
|
||||
<StarIcon sx={{ fontSize: '1rem', color: 'primary.main' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' }, alignItems: 'center' }}>
|
||||
<MuiRating value={rating.score} readOnly max={10} size="small" />
|
||||
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
</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>
|
||||
)}
|
||||
<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: 100 }, 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 }}>
|
||||
{/* Compact rating for mobile, stars for desktop */}
|
||||
<Box sx={{ display: { xs: 'flex', sm: 'none' }, alignItems: 'center', gap: 0.5 }}>
|
||||
<StarIcon sx={{ fontSize: '1rem', color: 'primary.main' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' }, alignItems: 'center' }}>
|
||||
<MuiRating value={rating.score} readOnly max={10} size="small" />
|
||||
<Typography variant="body2" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
|
||||
</Box>
|
||||
</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>
|
||||
))}
|
||||
|
||||
{nextPage && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
sx={{ borderRadius: 2, px: 4 }}
|
||||
>
|
||||
{loadingMore ? <CircularProgress size={24} /> : 'Load More Activity'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card sx={{ borderRadius: 3, position: { md: 'sticky' }, top: { md: 100 } }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box sx={{ p: 2, bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>Following ({following.length})</Typography>
|
||||
</Box>
|
||||
{following.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">You aren't following anyone yet.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ py: 0 }}>
|
||||
{following.map((user) => (
|
||||
<React.Fragment key={user.id}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton component={Link} to={`/profile/${user.username}`}>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={user.avatar || undefined} sx={{ width: 32, height: 32 }}>
|
||||
{!user.avatar && user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</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;
|
||||
Reference in new issue
Block a user