import React, { useEffect, useState } from 'react'; import { Typography, Box, CardContent, Avatar, Divider, Button, Grid, List, ListItemText, ListItemButton, Pagination, IconButton, Collapse, Link as MuiLink, Tabs, Tab, Skeleton, Chip, Stack, } from '@mui/material'; import CommentIcon from '@mui/icons-material/Comment'; import SendIcon from '@mui/icons-material/Send'; import WhatshotIcon from '@mui/icons-material/Whatshot'; import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; import ExploreIcon from '@mui/icons-material/Explore'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import BoltIcon from '@mui/icons-material/Bolt'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import { Link, useNavigate } from 'react-router-dom'; import { useCommunityFeed, useFollowedTopFlavors, useDiscoverFeed, useSuggestedUsers, useActivityFeed, type FeedRating, type DiscoverSort, type ActivityItem, } from '../api/queries/useCommunityFeed'; import { useCategories } from '../api/queries/useCategories'; import { useFollowToggle } from '../api/mutations/useSocialMutations'; import { useCreateReply } from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; import { useAuth } from '../hooks/useAuth'; import RichText from '../components/RichText'; import { formatDate } from '../utils/date'; import { useTranslation } from 'react-i18next'; import MentionTextField from '../components/MentionTextField'; import RatingBadge from '../components/RatingBadge'; import ReactionBar from '../components/ReactionBar'; import { PageShell, SectionHeader, GlassCard, FlavorThumb, EmptyState, BackButton, } from '../components/ui'; import { useToast } from '../hooks/useToast'; type FeedTab = 'following' | 'discover' | 'activity'; const SidebarCard: React.FC<{ icon: React.ReactNode; title: React.ReactNode; children: React.ReactNode; }> = ({ icon, title, children }) => ( {icon} {title} {children} ); /** Who-to-follow sidebar card. Optimistically drops a user from the list once * the caller follows them; reverts + toasts on failure. */ const SuggestedFollows: React.FC = () => { const { t } = useTranslation(); const { notify } = useToast(); const { data: suggestions = [] } = useSuggestedUsers(); const followToggle = useFollowToggle(); const [followed, setFollowed] = useState>(new Set()); const visible = suggestions.filter((u) => !followed.has(u.id)); if (visible.length === 0) return null; const handleFollow = async (userId: number) => { setFollowed((prev) => new Set(prev).add(userId)); try { await followToggle.mutateAsync({ userId, currentlyFollowing: false }); } catch { setFollowed((prev) => { const next = new Set(prev); next.delete(userId); return next; }); notify({ message: t('community.followFailed'), severity: 'error' }); } }; return ( } title={t('community.whoToFollow')} > {visible.map((u) => ( {!u.avatar && u.username.charAt(0).toUpperCase()} {u.username} {t('community.followerCount', { count: u.followers_count })} ))} ); }; const FeedSkeleton: React.FC = () => ( {[0, 1, 2].map((i) => ( ))} ); const CommunityFeed: React.FC = () => { const { t } = useTranslation(); useTitle(t('community.title')); const navigate = useNavigate(); const { notify } = useToast(); const [tab, setTab] = useState('following'); const [page, setPage] = useState(1); const [discoverSort, setDiscoverSort] = useState('recent'); const [discoverCategory, setDiscoverCategory] = useState(null); const [replyInputs, setReplyInputs] = useState>({}); const [expandedReplies, setExpandedReplies] = useState>({}); const { user } = useAuth(); const isAuthed = !!user; const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page); const { data: discover, isLoading: discoverLoading, error: discoverError, } = useDiscoverFeed({ page, sort: discoverSort, category: discoverCategory, enabled: tab === 'discover', }); const { data: activity, isLoading: activityLoading, error: activityError, } = useActivityFeed(page, tab === 'activity'); const { data: categories = [] } = useCategories(); const { data: topFollowed = [] } = useFollowedTopFlavors(); const createReply = useCreateReply(); const followingRatings: FeedRating[] = feed?.ratings ?? []; const discoverRatings: FeedRating[] = discover?.ratings ?? []; const activityItems: ActivityItem[] = activity?.items ?? []; const ratings = tab === 'discover' ? discoverRatings : followingRatings; const totalPages = (tab === 'following' ? feed?.totalPages : tab === 'discover' ? discover?.totalPages : activity?.totalPages) ?? 1; const loading = tab === 'following' ? feedLoading : tab === 'discover' ? discoverLoading : activityLoading; const handleTabChange = (next: FeedTab) => { setTab(next); setPage(1); }; const handleSortChange = (next: DiscoverSort) => { setDiscoverSort(next); setPage(1); }; const handleCategoryChange = (slug: string | null) => { setDiscoverCategory(slug); setPage(1); }; useEffect(() => { if (!isAuthed) navigate('/login'); }, [isAuthed, navigate]); useEffect(() => { const err = tab === 'following' ? feedError : tab === 'discover' ? discoverError : activityError; const status = (err as { response?: { status?: number } } | null)?.response?.status; if (status === 401) navigate('/login'); }, [feedError, discoverError, activityError, tab, navigate]); const handlePageChange = (_: React.ChangeEvent, value: number) => { setPage(value); 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 { await createReply.mutateAsync({ ratingId, text }); setReplyInputs((prev) => ({ ...prev, [ratingId]: '' })); } catch { notify({ message: t('common.replyFailed'), severity: 'error' }); } }; const renderCard = (rating: FeedRating) => { const expanded = !!expandedReplies[rating.id]; const latestReply = rating.replies[rating.replies.length - 1]; return ( {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} {rating.user} {formatDate(rating.created_at)} {user?.is_superuser && ( )} {t('community.rated')}{' '} {rating.flavor_name} {rating.comment ? ( ) : ( {t('common.noComment')} )} {!expanded && latestReply && ( handleReplyToggle(rating.id)} sx={{ mt: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'action.hover', cursor: 'pointer', display: 'flex', gap: 1, alignItems: 'baseline', minWidth: 0, }} > {latestReply.user} )} {rating.replies.map((reply) => ( {reply.user} {formatDate(reply.created_at)} {user?.is_superuser && ( )} ))} setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))} onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { handleReplySubmit(rating.id); } }} /> handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]} > ); }; const renderActivityCard = (item: ActivityItem) => { const isMilestone = item.kind === 'milestone'; return ( {isMilestone ? ( ) : ( )} {isMilestone ? t('community.milestoneLabel') : t('community.newDropLabel')} {isMilestone ? t('community.milestoneText', { flavor: item.flavor_name, count: item.milestone, }) : item.flavor_name} {formatDate(item.created_at)} ); }; const subtitle = tab === 'discover' ? t('community.discoverSubtitle') : tab === 'activity' ? t('community.activitySubtitle') : t('community.subtitle'); return ( handleTabChange(v)} sx={{ mb: 3, '& .MuiTab-root': { textTransform: 'none', fontWeight: 700 } }} > } iconPosition="start" label={t('community.tabFollowing')} /> } iconPosition="start" label={t('community.tabDiscover')} /> } iconPosition="start" label={t('community.tabActivity')} /> {tab === 'discover' && ( handleSortChange('recent')} /> handleSortChange('top')} /> handleCategoryChange(null)} /> {categories.map((cat) => ( handleCategoryChange(cat.slug)} /> ))} )} {tab === 'activity' ? ( loading && activityItems.length === 0 ? ( ) : activityItems.length === 0 ? ( {t('home.exploreFlavors')} } /> ) : ( {activityItems.map(renderActivityCard)} {totalPages > 1 && ( )} ) ) : loading && ratings.length === 0 ? ( ) : ratings.length === 0 ? ( tab === 'following' ? ( } onClick={() => handleTabChange('discover')} sx={{ borderRadius: 2 }} > {t('community.browseDiscover')} } /> ) : ( {t('home.exploreFlavors')} } /> ) ) : ( {ratings.map(renderCard)} {totalPages > 1 && ( )} )} } title={t('community.topRated')} > {topFollowed.map((flavor, idx) => ( {idx + 1} ))} ); }; export default CommunityFeed;