diff --git a/backend/api/views.py b/backend/api/views.py index 9b83e09..b1aaaef 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -97,6 +97,16 @@ class FlavorViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(newest_flavors, many=True) return Response(serializer.data) + @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) + def followed_top(self, request): + followed_users = request.user.following.all() + top_flavors = Flavor.objects.filter(ratings__user__in=followed_users) \ + .annotate(average_rating=Avg('ratings__score')) \ + .order_by('-average_rating') \ + .distinct()[:10] + serializer = self.get_serializer(top_flavors, many=True) + return Response(serializer.data) + class CategoryViewSet(viewsets.ReadOnlyModelViewSet): queryset = Category.objects.all() serializer_class = CategorySerializer diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7922a37..d30ebad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import api from './api'; import './i18n'; import { useTranslation } from 'react-i18next'; import Footer from './components/Footer'; +import { formatDate } from './utils/date'; // Code splitting const MainPage = lazy(() => import('./pages/MainPage')); @@ -533,7 +534,7 @@ const App: React.FC = () => { {n.actor_username} {n.notification_type === 'reply' ? 'replied to your review' : 'mentioned you'} on {n.flavor_name} - {new Date(n.created_at).toLocaleDateString()} + {formatDate(n.created_at)} diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 1e41ffd..2fd841d 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -16,7 +16,6 @@ import { ListItemButton, Pagination, TextField, - InputAdornment, alpha, IconButton, Collapse @@ -25,10 +24,13 @@ import SearchIcon from '@mui/icons-material/Search'; import CommentIcon from '@mui/icons-material/Comment'; import SendIcon from '@mui/icons-material/Send'; import WhatshotIcon from '@mui/icons-material/Whatshot'; -import { Link } from 'react-router-dom'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import PeopleIcon from '@mui/icons-material/People'; +import { Link, useNavigate } from 'react-router-dom'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; import RichText from '../components/RichText'; +import { formatDate } from '../utils/date'; interface Reply { id: number; @@ -59,19 +61,34 @@ interface FollowedUser { avatar: string | null; } +interface Notification { + id: number; + actor_username: string; + actor_avatar: string | null; + notification_type: 'reply' | 'mention'; + rating: number | null; + reply: number | null; + is_read: boolean; + created_at: string; + flavor_name: string | null; + flavor_id: number | null; +} + const CommunityFeed: React.FC = () => { useTitle('Community Feed'); + const navigate = useNavigate(); const [ratings, setRatings] = useState([]); const [following, setFollowing] = useState([]); + const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [followingSearch, setFollowingSearch] = useState(''); - // Trending Flavors State (for sidebar) - const [trending, setTrending] = useState([]); + // Top Rated Flavors by followed users + const [topFollowed, setTopFollowed] = useState([]); - // Reply State + // UI State const [replyInputs, setReplyInputs] = useState<{[key: number]: string}>({}); const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({}); @@ -82,10 +99,11 @@ const CommunityFeed: React.FC = () => { const fetchFeedData = async (pageNum: number) => { setLoading(true); try { - const [feedRes, followingRes, trendingRes] = await Promise.all([ + const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([ api.get(`ratings/feed/?page=${pageNum}`), api.get('users/following_list/'), - api.get('flavors/top/') // Using top as trending for now + api.get('flavors/followed_top/'), + api.get('notifications/') ]); const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []); @@ -97,9 +115,15 @@ const CommunityFeed: React.FC = () => { const followData = Array.isArray(followingRes.data) ? followingRes.data : (followingRes.data.results || []); setFollowing(followData); - setTrending(Array.isArray(trendingRes.data) ? trendingRes.data.slice(0, 5) : []); + setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []); + + const notifData = Array.isArray(notifRes.data) ? notifRes.data : (notifRes.data.results || []); + setNotifications(notifData.slice(0, 5)); // Just the latest 5 for sidebar - } catch (err) { + } catch (err: any) { + if (err.response?.status === 401) { + navigate('/login'); + } console.error('Failed to fetch feed data', err); } finally { setLoading(false); @@ -107,8 +131,13 @@ const CommunityFeed: React.FC = () => { }; useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + navigate('/login'); + return; + } fetchFeedData(page); - }, [page]); + }, [page, navigate]); const handlePageChange = (_: React.ChangeEvent, value: number) => { setPage(value); @@ -124,7 +153,6 @@ const CommunityFeed: React.FC = () => { if (!text) return; try { const res = await api.post(`ratings/${ratingId}/reply/`, { text }); - // Optimistically update the UI setRatings(prevRatings => prevRatings.map(r => { if (r.id === ratingId) { return { ...r, replies: [...r.replies, res.data] }; @@ -137,20 +165,24 @@ const CommunityFeed: React.FC = () => { } }; + const formatTimestamp = (dateStr: string) => { + return formatDate(dateStr); + }; + if (loading && ratings.length === 0) return ; return ( + + Community Activity + + Stay updated with your circle's latest ratings and discussions. + + + {/* Main Feed Column */} - - Community Feed - - See what your friends are drinking and rating. - - - {ratings.length === 0 ? ( { border: '1px dashed', borderColor: 'divider' }}> - It's quiet in here... + Your feed is a bit empty... - Follow more users to populate your feed with flavor reviews! + Follow more people to see their flavor ratings here! - ) : ( - + {ratings.map(rating => ( alpha(theme.palette.background.paper, 0.8), - backdropFilter: 'blur(12px)', - transition: 'transform 0.2s, box-shadow 0.2s', + transition: 'transform 0.2s', '&:hover': { - boxShadow: (theme) => `0 4px 20px ${alpha(theme.palette.primary.main, 0.08)}`, - borderColor: (theme) => alpha(theme.palette.primary.main, 0.3) + transform: 'translateY(-2px)', + borderColor: (theme) => alpha(theme.palette.primary.main, 0.2) } }}> - - {/* Header */} - + + {/* User Info */} + - + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} - + {rating.user} - - rated {rating.flavor_name} - - {new Date(rating.created_at).toLocaleDateString()} + {formatTimestamp(rating.created_at)} - - {/* Rating Badge */} alpha(theme.palette.primary.main, 0.1), color: 'primary.main', - px: 1.5, py: 0.5, - borderRadius: 2, - minWidth: 50 + px: 2, py: 0.5, + borderRadius: 2 }}> - {rating.score} - / 10 + {rating.score} + / 10 - {/* Content Body */} - + {/* Flavor and Comment */} + + + Rated {rating.flavor_name} + {rating.comment ? ( - + ) : ( - - No review text provided. - + No review text. )} - {/* Flavor Thumbnail */} - - + + - + - {/* Actions / Footer */} - - - - {/* Small timestamp or generic action */} - - + {/* Footer & Replies */} + - {/* Replies Section (Collapsible) */} - {/* Existing Replies */} {rating.replies.map((reply) => ( - - - {reply.user.charAt(0).toUpperCase()} - - - {reply.user} - - - {new Date(reply.created_at).toLocaleDateString()} - + + {reply.user} + {formatTimestamp(reply.created_at)} - - - + ))} - - {/* Reply Input */} setReplyInputs(prev => ({ ...prev, [rating.id]: e.target.value }))} - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 4, bgcolor: 'background.paper' } }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleReplySubmit(rating.id); - } - }} + onKeyDown={(e) => e.key === 'Enter' && handleReplySubmit(rating.id)} /> - handleReplySubmit(rating.id)} - sx={{ bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' }, width: 40, height: 40 }} - > - + handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]}> + @@ -329,92 +309,87 @@ const CommunityFeed: React.FC = () => { ))} - {totalPages > 1 && ( - - + + )} )} - {/* Sidebar - Desktop Only */} - - + {/* Sidebar Column */} + + - {/* Following Widget */} - - - Following - - - {following.length === 0 ? ( - - You aren't following anyone yet. - - ) : ( - - {filteredFollowing.map((user) => ( - - - - {!user.avatar && user.username.charAt(0).toUpperCase()} - - - - - ))} - - )} - - - setFollowingSearch(e.target.value)} - InputProps={{ - startAdornment: , - sx: { borderRadius: 2, fontSize: '0.85rem' } - }} - /> - - - - {/* Trending / Top Rated Widget */} - - - - Top Rated Flavors + {/* Notifications Widget */} + + + + Recent Notifications - {trending.map((flavor, index) => ( - - - {index + 1} - - - F - + {notifications.length === 0 ? ( + No notifications. + ) : ( + notifications.map(n => ( + n.flavor_id && navigate(`/flavor/${n.flavor_id}`)} sx={{ py: 1.5 }}> + + {n.actor_username.charAt(0)} + + + {n.actor_username} {n.notification_type === 'reply' ? 'replied' : 'mentioned you'} + + } + secondary={formatTimestamp(n.created_at)} + secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }} + /> + + )) + )} + + + + {/* Following Widget */} + + + + Following + + + setFollowingSearch(e.target.value)} + InputProps={{ startAdornment: }} + /> + + + {filteredFollowing.map(user => ( + + {user.username.charAt(0)} + + + ))} + + + + {/* Top Rated by Circle */} + + + + Circle's Top Rated + + + {topFollowed.map((flavor, idx) => ( + + {idx + 1} + diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 2113170..66b19a8 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -22,6 +22,7 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; +import { formatDate } from '../utils/date'; interface Flavor { id: number; @@ -275,7 +276,7 @@ const MainPage: React.FC = () => { rated - {new Date(rating.created_at).toLocaleDateString()} + {formatDate(rating.created_at)} @@ -499,7 +500,7 @@ const MainPage: React.FC = () => { - {new Date(review.created_at).toLocaleDateString()} • on {review.flavor_name} + {formatDate(review.created_at)} • on {review.flavor_name} "{review.comment}" diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index 27b9f1a..93cb0dd 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -25,6 +25,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import VerifiedIcon from '@mui/icons-material/Verified'; import StarIcon from '@mui/icons-material/Star'; +import { formatDate } from '../../utils/date'; interface Reply { id: number; @@ -406,7 +407,7 @@ const FlavorDetail: React.FC = () => { {rating.user} - {new Date(rating.created_at).toLocaleDateString()} + {formatDate(rating.created_at)} @@ -466,7 +467,7 @@ const FlavorDetail: React.FC = () => { - {new Date(reply.created_at).toLocaleDateString()} {new Date(reply.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {formatDate(reply.created_at)} {currentUser === reply.user && editingReplyId !== reply.id && ( diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000..ba21e24 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,10 @@ +export const formatDate = (dateStr: string) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = d.getFullYear(); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + return `${day}/${month}/${year} ${hours}:${minutes}`; +};