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 */}
-
- }
- onClick={() => handleReplyToggle(rating.id)}
- sx={{ textTransform: 'none', color: 'text.secondary', '&:hover': { color: 'primary.main', bgcolor: 'transparent' } }}
- >
- {rating.replies.length > 0 ? `${rating.replies.length} Replies` : 'Reply'}
-
-
- {/* Small timestamp or generic action */}
-
-
+ {/* Footer & Replies */}
+ }
+ onClick={() => handleReplyToggle(rating.id)}
+ sx={{ textTransform: 'none', color: 'text.secondary' }}
+ >
+ {rating.replies.length > 0 ? `${rating.replies.length} Replies` : 'Reply'}
+
- {/* 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}`;
+};