diff --git a/frontend/src/components/RichText.tsx b/frontend/src/components/RichText.tsx new file mode 100644 index 0000000..5cab81f --- /dev/null +++ b/frontend/src/components/RichText.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useTheme, alpha } from '@mui/material'; + +interface RichTextProps { + text: string; +} + +const RichText: React.FC = ({ text }) => { + const theme = useTheme(); + + if (!text) return null; + + const parts = text.split(/(@\w+)/g); + return ( + + {parts.map((part, i) => { + if (part.startsWith('@')) { + const username = part.substring(1); + return ( + + {part} + + ); + } + return part; + })} + + ); +}; + +export default RichText; diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 4536971..2236cdb 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -19,12 +19,27 @@ import { Pagination, TextField, InputAdornment, - alpha + alpha, + IconButton, + Tooltip, + Collapse } from '@mui/material'; 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 VerifiedIcon from '@mui/icons-material/Verified'; import { Link } from 'react-router-dom'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; +import RichText from '../components/RichText'; + +interface Reply { + id: number; + user: string; + text: string; + created_at: string; +} interface FeedRating { id: number; @@ -39,6 +54,7 @@ interface FeedRating { score: number; comment: string; created_at: string; + replies: Reply[]; } interface FollowedUser { @@ -55,6 +71,13 @@ const CommunityFeed: React.FC = () => { const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [followingSearch, setFollowingSearch] = useState(''); + + // Trending Flavors State (for sidebar) + const [trending, setTrending] = useState([]); + + // Reply State + const [replyInputs, setReplyInputs] = useState<{[key: number]: string}>({}); + const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({}); const filteredFollowing = following.filter(user => user.username.toLowerCase().includes(followingSearch.toLowerCase()) @@ -63,19 +86,23 @@ const CommunityFeed: React.FC = () => { const fetchFeedData = async (pageNum: number) => { setLoading(true); try { - const [feedRes, followingRes] = await Promise.all([ + const [feedRes, followingRes, trendingRes] = await Promise.all([ api.get(`ratings/feed/?page=${pageNum}`), - api.get('users/following_list/') + api.get('users/following_list/'), + api.get('flavors/top/') // Using top as trending for now ]); - const feedData = feedRes.data.results || []; + const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []); setRatings(feedData); - // Calculate total pages (DRF returns total count) const count = feedRes.data.count || 0; - setTotalPages(Math.ceil(count / 10)); // 10 is our page_size + setTotalPages(Math.ceil(count / 10)); - setFollowing(followingRes.data); + const followData = Array.isArray(followingRes.data) ? followingRes.data : (followingRes.data.results || []); + setFollowing(followData); + + setTrending(Array.isArray(trendingRes.data) ? trendingRes.data.slice(0, 5) : []); + } catch (err) { console.error('Failed to fetch feed data', err); } finally { @@ -92,195 +119,314 @@ const CommunityFeed: React.FC = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; + const handleReplyToggle = (ratingId: number) => { + setExpandedReplies(prev => ({ ...prev, [ratingId]: !prev[ratingId] })); + }; + + const handleReplySubmit = async (ratingId: number) => { + const text = replyInputs[ratingId]; + 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] }; + } + return r; + })); + setReplyInputs(prev => ({ ...prev, [ratingId]: '' })); + } catch (err) { + alert('Failed to send reply'); + } + }; + if (loading && ratings.length === 0) return ; return ( - - Community Activity - - Discover which Holy Energy flavors your circle is currently testing. - See their latest ratings, read their reviews, and find your next favorite together. - - - - {/* Main Feed */} + {/* Main Feed Column */} + + Community Feed + + See what your friends are drinking and rating. + + + {ratings.length === 0 ? ( - - Your feed is quiet... - - Follow other Holy Energy fans to see their latest flavor ratings and reviews here! + alpha(theme.palette.background.paper, 0.6), + backdropFilter: 'blur(10px)', + border: '1px dashed', + borderColor: 'divider' + }}> + It's quiet in here... + + Follow more users to populate your feed with flavor reviews! - ) : ( - - {loading ? ( - - ) : ( - <> - {ratings.map(rating => ( - - - - - - {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} - + + {ratings.map(rating => ( + alpha(theme.palette.background.paper, 0.8), + backdropFilter: 'blur(12px)', + transition: 'transform 0.2s, box-shadow 0.2s', + '&:hover': { + boxShadow: (theme) => `0 4px 20px ${alpha(theme.palette.primary.main, 0.08)}`, + borderColor: (theme) => alpha(theme.palette.primary.main, 0.3) + } + }}> + + {/* Header */} + + + + + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} + + - - {rating.user} - + + + {rating.user} + + + rated {rating.flavor_name} + + {new Date(rating.created_at).toLocaleDateString()} - + + {/* Rating Badge */} + alpha(theme.palette.primary.main, 0.1), + color: 'primary.main', + px: 1.5, py: 0.5, + borderRadius: 2, + minWidth: 50 + }}> + {rating.score} + / 10 + + - - - - - - - - - - - - {rating.flavor_name} - - - - {rating.category_name} + {/* Content Body */} + + + {rating.comment ? ( + + - - - {/* Compact rating for mobile, stars for desktop */} - - ⭐ {rating.score}/10 - - - - {rating.score}/10 - - + ) : ( + + No review text provided. + + )} + + {/* Flavor Thumbnail */} + + + + + + - {rating.comment && ( - - "{rating.comment}" + + + {/* Actions / Footer */} + + + + {/* Small timestamp or generic action */} + + + + {/* Replies Section (Collapsible) */} + + + {/* Existing Replies */} + {rating.replies.map((reply) => ( + + + + {reply.user.charAt(0).toUpperCase()} + + + {reply.user} + + + {new Date(reply.created_at).toLocaleDateString()} + + + + - )} + + ))} + + {/* 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); + } + }} + /> + handleReplySubmit(rating.id)} + sx={{ bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' }, width: 40, height: 40 }} + > + + - - - ))} - - )} - - {totalPages > 1 && ( - - - - )} + + + + ))} + + {totalPages > 1 && ( + + + + )} )} {/* Sidebar - Desktop Only */} - - - alpha(theme.palette.background.paper, 0.4), - backdropFilter: 'blur(8px)', - borderBottom: '1px solid', - borderColor: 'divider' - }}> - Following ({following.length}) - setFollowingSearch(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - bgcolor: 'background.paper', - borderRadius: 1, - '& .MuiOutlinedInput-root': { - '& fieldset': { border: 'none' }, - boxShadow: '0 1px 3px rgba(0,0,0,0.05)' - } - }} - /> + + + {/* Following Widget */} + + + Following - {following.length === 0 ? ( - - You aren't following anyone yet. - - ) : ( - - {filteredFollowing.map((user) => ( - - - - - - {!user.avatar && user.username.charAt(0).toUpperCase()} - - - - - - - - ))} - {filteredFollowing.length === 0 && followingSearch && ( - - No matching friends found. + + {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 + + + {trending.map((flavor, index) => ( + + + {index + 1} - )} - - )} - - + + F + + + + ))} + + +