community tab redesign

This commit is contained in:
nvrl committed 2026-06-16 19:59:52 +02:00
1 parent 1fb318f6a1
commit 2f2935a1cb
18 files changed
+1222 -70

No files matched your search

+2
View File
@@ -17,4 +17,6 @@ export const queryKeys = {
activeBanner: (username: string) => ['activeBanner', username] as const,
notifications: () => ['notifications'] as const,
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
communityDiscover: (params: Record<string, unknown>) => ['communityDiscover', params] as const,
communityActivity: (params: Record<string, unknown>) => ['communityActivity', params] as const,
};
@@ -23,6 +23,8 @@ export interface FeedRating {
comment: string;
created_at: string;
replies: FeedReply[];
reactions?: Record<string, number>;
my_reactions?: string[];
}
export interface FollowedUser {
@@ -60,6 +62,77 @@ export const useCommunityFeed = (page: number) =>
placeholderData: (prev) => prev,
});
export interface ActivityItem {
kind: 'new_flavor' | 'milestone';
id: string;
created_at: string;
flavor_id: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
category_slug: string;
milestone?: number;
rating_count?: number;
}
export interface ActivityResult {
items: ActivityItem[];
totalPages: number;
}
/** Community activity stream — new-flavor drops + rating milestones, paginated. */
export const useActivityFeed = (page: number, enabled = true) =>
useQuery({
queryKey: queryKeys.communityActivity({ page }),
queryFn: async (): Promise<ActivityResult> => {
const res = await api.get<{ results?: ActivityItem[]; count?: number } | ActivityItem[]>(
`activity/?page=${page}`,
);
if (Array.isArray(res.data)) {
return { items: res.data, totalPages: 1 };
}
const items = res.data.results ?? [];
const totalPages = Math.ceil((res.data.count ?? 0) / 10) || 1;
return { items, totalPages };
},
enabled,
placeholderData: (prev) => prev,
});
export type DiscoverSort = 'recent' | 'top';
export interface DiscoverParams {
page: number;
sort: DiscoverSort;
category: string | null;
enabled?: boolean;
}
/**
* Global Discover feed (`ratings/discover/`) — paginated, sortable (recent|top),
* filterable by category slug. Unlike `useRecentRatings` this is a real paginated
* feed, so the Discover tab gets sort + filter + pages.
*/
export const useDiscoverFeed = ({ page, sort, category, enabled = true }: DiscoverParams) =>
useQuery({
queryKey: queryKeys.communityDiscover({ page, sort, category: category ?? '' }),
queryFn: async (): Promise<CommunityFeedResult> => {
const qs = new URLSearchParams({ page: String(page), sort });
if (category) qs.set('category', category);
const res = await api.get<{ results?: FeedRating[]; count?: number } | FeedRating[]>(
`ratings/discover/?${qs.toString()}`,
);
if (Array.isArray(res.data)) {
return { ratings: res.data, totalPages: 1 };
}
const ratings = res.data.results ?? [];
const totalPages = Math.ceil((res.data.count ?? 0) / 10) || 1;
return { ratings, totalPages };
},
enabled,
placeholderData: (prev) => prev,
});
export const useRecentRatings = (enabled = true) =>
useQuery({
queryKey: ['recentRatings'],
@@ -81,6 +154,25 @@ export const useFollowingList = () =>
},
});
export interface SuggestedUser {
id: number;
username: string;
avatar: string | null;
followers_count: number;
is_following: boolean;
}
/** Who-to-follow: most-active raters the caller doesn't follow yet (max 5). */
export const useSuggestedUsers = (enabled = true) =>
useQuery({
queryKey: ['suggestedUsers'],
queryFn: async (): Promise<SuggestedUser[]> => {
const res = await api.get<SuggestedUser[] | { results: SuggestedUser[] }>('users/suggested/');
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
enabled,
});
export const useFollowedTopFlavors = () =>
useQuery({
queryKey: ['followedTopFlavors'],
+139
View File
@@ -0,0 +1,139 @@
import React, { useState } from 'react';
import { Box, Tooltip, ButtonBase } from '@mui/material';
import { useTranslation } from 'react-i18next';
import api from '../lib/api';
import { useToast } from '../hooks/useToast';
/** Reaction kinds, in display order. Must match the backend allow-list
* (`REACTION_KINDS` in `routes/ratings.rs`). */
export const REACTION_KINDS = ['like', 'love', 'fire', 'yum', 'mind', 'meh'] as const;
export type ReactionKind = (typeof REACTION_KINDS)[number];
const EMOJI: Record<ReactionKind, string> = {
like: '👍',
love: '❤️',
fire: '🔥',
yum: '😋',
mind: '🤯',
meh: '😐',
};
interface ServerRating {
reactions?: Record<string, number>;
my_reactions?: string[];
}
interface ReactionBarProps {
ratingId: number;
reactions?: Record<string, number>;
myReactions?: string[];
/** When false the bar is read-only (counts only, no toggling). */
canReact?: boolean;
}
/**
* Emoji reaction bar for a rating. Owns its own optimistic state seeded from
* props, so a toggle feels instant; the server response (full reaction map) then
* reconciles the counts. Errors revert and surface a toast.
*/
const ReactionBar: React.FC<ReactionBarProps> = ({
ratingId,
reactions = {},
myReactions = [],
canReact = true,
}) => {
const { t } = useTranslation();
const { notify } = useToast();
const [counts, setCounts] = useState<Record<string, number>>(reactions);
const [mine, setMine] = useState<Set<string>>(new Set(myReactions));
const [busy, setBusy] = useState(false);
const toggle = async (kind: ReactionKind) => {
if (!canReact || busy) return;
const active = mine.has(kind);
const prevCounts = counts;
const prevMine = mine;
// Optimistic: flip membership + nudge the count.
const nextMine = new Set(mine);
const nextCounts = { ...counts };
if (active) {
nextMine.delete(kind);
nextCounts[kind] = Math.max(0, (nextCounts[kind] ?? 1) - 1);
} else {
nextMine.add(kind);
nextCounts[kind] = (nextCounts[kind] ?? 0) + 1;
}
setMine(nextMine);
setCounts(nextCounts);
setBusy(true);
try {
const res = await api.request<ServerRating>({
url: `ratings/${ratingId}/react/`,
method: active ? 'delete' : 'post',
data: { kind },
});
// Reconcile against the server's authoritative map (covers others' reactions).
setCounts(res.data.reactions ?? {});
setMine(new Set(res.data.my_reactions ?? []));
} catch {
setCounts(prevCounts);
setMine(prevMine);
notify({ message: t('community.reactionFailed'), severity: 'error' });
} finally {
setBusy(false);
}
};
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
{REACTION_KINDS.map((kind) => {
const count = counts[kind] ?? 0;
const active = mine.has(kind);
// Hide zero-count reactions the viewer hasn't picked, to keep the bar tidy.
if (count === 0 && !active && !canReact) return null;
return (
<Tooltip key={kind} title={t(`community.reactions.${kind}`)} arrow>
<ButtonBase
onClick={() => toggle(kind)}
disabled={!canReact}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 0.9,
py: 0.3,
borderRadius: 5,
fontSize: '0.95rem',
lineHeight: 1,
border: '1px solid',
borderColor: active ? 'primary.main' : 'divider',
bgcolor: active ? 'primary.main' : 'transparent',
color: active ? 'primary.contrastText' : 'text.secondary',
opacity: count === 0 && !active ? 0.55 : 1,
transition: 'all 0.15s ease',
cursor: canReact ? 'pointer' : 'default',
'&:hover': canReact
? {
borderColor: 'primary.main',
bgcolor: active ? 'primary.dark' : 'action.hover',
}
: undefined,
}}
>
<span>{EMOJI[kind]}</span>
{count > 0 && (
<Box component="span" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
{count}
</Box>
)}
</ButtonBase>
</Tooltip>
);
})}
</Box>
);
};
export default ReactionBar;
@@ -67,12 +67,7 @@ export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => {
scrollbarWidth: 'none',
}}
>
<Button
component={Link}
to="/"
disableRipple
sx={{ ...tabSx(isActive('/')), ml: -1.75 }}
>
<Button component={Link} to="/" disableRipple sx={{ ...tabSx(isActive('/')), ml: -1.75 }}>
<HomeOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
{t('nav.home')}
</Button>
@@ -85,12 +80,7 @@ export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => {
)}
{user && (
<Button
component={Link}
to="/dashboard"
disableRipple
sx={tabSx(isActive('/dashboard'))}
>
<Button component={Link} to="/dashboard" disableRipple sx={tabSx(isActive('/dashboard'))}>
<CollectionsBookmarkOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
{t('nav.dashboard')}
</Button>
+48
View File
@@ -299,6 +299,30 @@ const resources = {
discoverEmpty: 'No recent activity yet.',
discoverEmptyHint: 'Be the first to rate a flavor!',
browseDiscover: 'Browse Discover',
sortRecent: 'Recent',
sortTop: 'Top rated',
allCategories: 'All',
whoToFollow: 'Who to follow',
follow: 'Follow',
followFailed: 'Could not follow this user.',
followerCount_one: '{{count}} follower',
followerCount_other: '{{count}} followers',
tabActivity: 'Activity',
activitySubtitle: 'Fresh drops and milestones from across the catalog.',
activityEmpty: 'Nothing new yet.',
activityEmptyHint: 'New flavors and milestones will show up here.',
newDropLabel: 'New flavor drop',
milestoneLabel: 'Milestone',
milestoneText: '{{flavor}} hit {{count}} ratings',
reactionFailed: 'Could not save your reaction.',
reactions: {
like: 'Like',
love: 'Love',
fire: 'Fire',
yum: 'Yum',
mind: 'Mind blown',
meh: 'Meh',
},
},
support: {
title: 'Help & Support',
@@ -842,6 +866,30 @@ const resources = {
discoverEmpty: 'Noch keine Aktivität.',
discoverEmptyHint: 'Sei der Erste, der einen Geschmack bewertet!',
browseDiscover: 'Entdecken durchstöbern',
sortRecent: 'Neueste',
sortTop: 'Top bewertet',
allCategories: 'Alle',
whoToFollow: 'Wem folgen',
follow: 'Folgen',
followFailed: 'Konnte diesem Nutzer nicht folgen.',
followerCount_one: '{{count}} Follower',
followerCount_other: '{{count}} Follower',
tabActivity: 'Aktivität',
activitySubtitle: 'Neue Sorten und Meilensteine aus dem ganzen Katalog.',
activityEmpty: 'Noch nichts Neues.',
activityEmptyHint: 'Neue Sorten und Meilensteine erscheinen hier.',
newDropLabel: 'Neue Sorte',
milestoneLabel: 'Meilenstein',
milestoneText: '{{flavor}} erreichte {{count}} Bewertungen',
reactionFailed: 'Reaktion konnte nicht gespeichert werden.',
reactions: {
like: 'Gefällt mir',
love: 'Liebe',
fire: 'Feuer',
yum: 'Lecker',
mind: 'Umgehauen',
meh: 'Naja',
},
},
support: {
title: 'Hilfe & Support',
+304 -25
View File
@@ -17,19 +17,31 @@ import {
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,
useRecentRatings,
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';
@@ -38,6 +50,7 @@ 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,
HeroBackdrop,
@@ -49,7 +62,7 @@ import {
} from '../components/ui';
import { useToast } from '../hooks/useToast';
type FeedTab = 'following' | 'discover';
type FeedTab = 'following' | 'discover' | 'activity';
const SidebarCard: React.FC<{
icon: React.ReactNode;
@@ -76,6 +89,74 @@ const SidebarCard: React.FC<{
</GlassCard>
);
/** 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<Set<number>>(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 (
<SidebarCard
icon={<PersonAddIcon color="primary" fontSize="small" />}
title={t('community.whoToFollow')}
>
<List disablePadding>
{visible.map((u) => (
<Box key={u.id} sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.25 }}>
<Link to={`/profile/${u.username}`} style={{ textDecoration: 'none' }}>
<Avatar src={u.avatar || undefined} sx={{ width: 36, height: 36 }}>
{!u.avatar && u.username.charAt(0).toUpperCase()}
</Avatar>
</Link>
<Box sx={{ minWidth: 0, flex: 1 }}>
<MuiLink
component={Link}
to={`/profile/${u.username}`}
sx={{ color: 'inherit', textDecoration: 'none', fontWeight: 700 }}
>
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
{u.username}
</Typography>
</MuiLink>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
{t('community.followerCount', { count: u.followers_count })}
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={() => handleFollow(u.id)}
sx={{ textTransform: 'none', borderRadius: 2, flexShrink: 0, fontWeight: 700 }}
>
{t('community.follow')}
</Button>
</Box>
))}
</List>
</SidebarCard>
);
};
const FeedSkeleton: React.FC = () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{[0, 1, 2].map((i) => (
@@ -112,6 +193,8 @@ const CommunityFeed: React.FC = () => {
const { notify } = useToast();
const [tab, setTab] = useState<FeedTab>('following');
const [page, setPage] = useState(1);
const [discoverSort, setDiscoverSort] = useState<DiscoverSort>('recent');
const [discoverCategory, setDiscoverCategory] = useState<string | null>(null);
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
@@ -122,25 +205,59 @@ const CommunityFeed: React.FC = () => {
data: discover,
isLoading: discoverLoading,
error: discoverError,
} = useRecentRatings(tab === 'discover');
} = 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 ?? [];
const ratings = tab === 'following' ? followingRatings : discoverRatings;
const totalPages = feed?.totalPages ?? 1;
const loading = tab === 'following' ? feedLoading : discoverLoading;
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 : discoverError;
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, tab, navigate]);
}, [feedError, discoverError, activityError, tab, navigate]);
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
@@ -242,22 +359,42 @@ const CommunityFeed: React.FC = () => {
)}
</Box>
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
<FlavorThumb src={rating.flavor_image} name={rating.flavor_name} size={70} radius={1} />
<FlavorThumb
src={rating.flavor_image}
name={rating.flavor_name}
size={70}
radius={1}
/>
</Link>
</Box>
<Divider sx={{ my: 2 }} />
<Button
size="small"
startIcon={<CommentIcon fontSize="small" />}
onClick={() => handleReplyToggle(rating.id)}
sx={{ textTransform: 'none', color: 'text.secondary' }}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{rating.replies.length > 0
? `${rating.replies.length} ${t('common.replies')}`
: t('common.reply')}
</Button>
<ReactionBar
ratingId={rating.id}
reactions={rating.reactions}
myReactions={rating.my_reactions}
/>
<Button
size="small"
startIcon={<CommentIcon fontSize="small" />}
onClick={() => handleReplyToggle(rating.id)}
sx={{ textTransform: 'none', color: 'text.secondary', flexShrink: 0 }}
>
{rating.replies.length > 0
? `${rating.replies.length} ${t('common.replies')}`
: t('common.reply')}
</Button>
</Box>
{!expanded && latestReply && (
<Box
@@ -275,7 +412,10 @@ const CommunityFeed: React.FC = () => {
minWidth: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}
>
{latestReply.user}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap sx={{ minWidth: 0 }}>
@@ -346,8 +486,63 @@ const CommunityFeed: React.FC = () => {
);
};
const renderActivityCard = (item: ActivityItem) => {
const isMilestone = item.kind === 'milestone';
return (
<GlassCard key={item.id} intensity="default">
<CardContent sx={{ p: { xs: 2, sm: 2.5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: '50%',
flexShrink: 0,
color: isMilestone ? 'warning.main' : 'success.main',
bgcolor: isMilestone ? 'warning.main' : 'success.main',
opacity: 0.9,
}}
>
{isMilestone ? (
<EmojiEventsIcon sx={{ color: 'common.white' }} />
) : (
<NewReleasesIcon sx={{ color: 'common.white' }} />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary">
{isMilestone ? t('community.milestoneLabel') : t('community.newDropLabel')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{isMilestone
? t('community.milestoneText', {
flavor: item.flavor_name,
count: item.milestone,
})
: item.flavor_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(item.created_at)}
</Typography>
</Box>
<Link to={`/flavor/${item.flavor_id}`} style={{ flexShrink: 0 }}>
<FlavorThumb src={item.flavor_image} name={item.flavor_name} size={56} radius={1} />
</Link>
</Box>
</CardContent>
</GlassCard>
);
};
const subtitle =
tab === 'discover' ? t('community.discoverSubtitle') : t('community.subtitle');
tab === 'discover'
? t('community.discoverSubtitle')
: tab === 'activity'
? t('community.activitySubtitle')
: t('community.subtitle');
return (
<PageShell hero={<HeroBackdrop variant="minimal" />}>
@@ -357,7 +552,7 @@ const CommunityFeed: React.FC = () => {
<Tabs
value={tab}
onChange={(_, v: FeedTab) => setTab(v)}
onChange={(_, v: FeedTab) => handleTabChange(v)}
sx={{ mb: 3, '& .MuiTab-root': { textTransform: 'none', fontWeight: 700 } }}
>
<Tab
@@ -372,11 +567,94 @@ const CommunityFeed: React.FC = () => {
iconPosition="start"
label={t('community.tabDiscover')}
/>
<Tab
value="activity"
icon={<BoltIcon fontSize="small" />}
iconPosition="start"
label={t('community.tabActivity')}
/>
</Tabs>
{tab === 'discover' && (
<Box
sx={{
position: 'sticky',
top: 8,
zIndex: 2,
mb: 3,
py: 1,
display: 'flex',
flexWrap: 'wrap',
gap: 1,
alignItems: 'center',
}}
>
<Stack direction="row" spacing={1}>
<Chip
label={t('community.sortRecent')}
color={discoverSort === 'recent' ? 'primary' : 'default'}
variant={discoverSort === 'recent' ? 'filled' : 'outlined'}
onClick={() => handleSortChange('recent')}
/>
<Chip
label={t('community.sortTop')}
color={discoverSort === 'top' ? 'primary' : 'default'}
variant={discoverSort === 'top' ? 'filled' : 'outlined'}
onClick={() => handleSortChange('top')}
/>
</Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip
label={t('community.allCategories')}
color={discoverCategory === null ? 'secondary' : 'default'}
variant={discoverCategory === null ? 'filled' : 'outlined'}
onClick={() => handleCategoryChange(null)}
/>
{categories.map((cat) => (
<Chip
key={cat.slug}
label={t(`categories.${cat.slug}`, { defaultValue: cat.name })}
color={discoverCategory === cat.slug ? 'secondary' : 'default'}
variant={discoverCategory === cat.slug ? 'filled' : 'outlined'}
onClick={() => handleCategoryChange(cat.slug)}
/>
))}
</Box>
</Box>
)}
<Grid container spacing={4}>
<Grid size={{ xs: 12, lg: 8 }}>
{loading && ratings.length === 0 ? (
{tab === 'activity' ? (
loading && activityItems.length === 0 ? (
<FeedSkeleton />
) : activityItems.length === 0 ? (
<EmptyState
title={t('community.activityEmpty')}
subtitle={t('community.activityEmptyHint')}
action={
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
{t('home.exploreFlavors')}
</Button>
}
/>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{activityItems.map(renderActivityCard)}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box>
)}
</Box>
)
) : loading && ratings.length === 0 ? (
<FeedSkeleton />
) : ratings.length === 0 ? (
tab === 'following' ? (
@@ -387,7 +665,7 @@ const CommunityFeed: React.FC = () => {
<Button
variant="contained"
startIcon={<ExploreIcon />}
onClick={() => setTab('discover')}
onClick={() => handleTabChange('discover')}
sx={{ borderRadius: 2 }}
>
{t('community.browseDiscover')}
@@ -408,7 +686,7 @@ const CommunityFeed: React.FC = () => {
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{ratings.map(renderCard)}
{tab === 'following' && totalPages > 1 && (
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={totalPages}
@@ -424,6 +702,7 @@ const CommunityFeed: React.FC = () => {
<Grid size={{ xs: 12, lg: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<SuggestedFollows />
<SidebarCard
icon={<WhatshotIcon color="error" fontSize="small" />}
title={t('community.topRated')}