mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
mainpage redesign
This commit is contained in:
1 parent
ecedabe8b0
commit
6f5f3eaf37
2 files changed
+371
-437
No files matched your search
@@ -98,11 +98,25 @@ const resources = {
|
||||
followMore: 'Follow more users to see their activity here!',
|
||||
exploreFlavors: 'Explore Flavors',
|
||||
hallOfFame: 'Hall of Fame',
|
||||
hallOfFameSubtitle: 'Highest-rated flavors in the archive',
|
||||
communityVoice: 'Community Voice',
|
||||
newArrivals: 'New Arrivals',
|
||||
newArrivalsSubtitle: 'The latest additions to the catalog',
|
||||
viewAllReviews: 'View All Reviews',
|
||||
viewMoreReviews: 'View more reviews',
|
||||
rated: 'rated',
|
||||
archiveTitle: 'The Holy Archive',
|
||||
archiveSubtitle:
|
||||
'Top-rated flavors and the latest arrivals. Use the search bar to find anything.',
|
||||
searchResultsTitle: 'Search results for "{{query}}"',
|
||||
searchResultsCount_one: 'Found {{count}} item',
|
||||
searchResultsCount_other: 'Found {{count}} items',
|
||||
noSearchResults: 'No flavors match "{{query}}".',
|
||||
noTopFlavors: 'No top-rated flavors yet.',
|
||||
noNewest: 'No new arrivals yet.',
|
||||
previousFlavor: 'Previous flavor',
|
||||
nextFlavor: 'Next flavor',
|
||||
goToFlavor: 'Go to flavor {{n}}',
|
||||
},
|
||||
common: {
|
||||
search: 'Search...',
|
||||
@@ -382,11 +396,25 @@ const resources = {
|
||||
followMore: 'Folge mehr Benutzern, um deren Aktivitäten hier zu sehen!',
|
||||
exploreFlavors: 'Sorten entdecken',
|
||||
hallOfFame: 'Hall of Fame',
|
||||
hallOfFameSubtitle: 'Die bestbewerteten Sorten im Archiv',
|
||||
communityVoice: 'Community-Stimmen',
|
||||
newArrivals: 'Neuheiten',
|
||||
newArrivalsSubtitle: 'Die neuesten Zugänge im Katalog',
|
||||
viewAllReviews: 'Alle Bewertungen ansehen',
|
||||
viewMoreReviews: 'Mehr Bewertungen',
|
||||
rated: 'bewertete',
|
||||
archiveTitle: 'Das Holy-Archiv',
|
||||
archiveSubtitle:
|
||||
'Top-bewertete Sorten und die neuesten Zugänge. Nutze die Suche, um alles zu finden.',
|
||||
searchResultsTitle: 'Suchergebnisse für „{{query}}"',
|
||||
searchResultsCount_one: '{{count}} Treffer',
|
||||
searchResultsCount_other: '{{count}} Treffer',
|
||||
noSearchResults: 'Keine Sorten passen zu „{{query}}".',
|
||||
noTopFlavors: 'Noch keine bestbewerteten Sorten.',
|
||||
noNewest: 'Noch keine Neuheiten.',
|
||||
previousFlavor: 'Vorherige Sorte',
|
||||
nextFlavor: 'Nächste Sorte',
|
||||
goToFlavor: 'Zu Sorte {{n}}',
|
||||
},
|
||||
common: {
|
||||
search: 'Suchen...',
|
||||
|
||||
+343
-437
@@ -1,27 +1,22 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Avatar,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import { useCategories } from '../api/queries/useFlavorsList';
|
||||
import {
|
||||
useFlavorSearch,
|
||||
useFollowingFeedTop,
|
||||
useNewestFlavors,
|
||||
useRecentReviews,
|
||||
useTopFlavors,
|
||||
} from '../api/queries/useMainPageQueries';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
@@ -32,211 +27,173 @@ import {
|
||||
PageShell,
|
||||
SectionHeader,
|
||||
HeroBackdrop,
|
||||
ReviewRow,
|
||||
FlavorCard,
|
||||
EmptyState,
|
||||
} from '../components/ui';
|
||||
|
||||
interface MainPageProps {
|
||||
adminMode?: boolean;
|
||||
}
|
||||
|
||||
const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
const TOP_LIMIT = 5;
|
||||
|
||||
const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
|
||||
<Box sx={{ height: '100%' }}>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<Skeleton variant="text" sx={{ mt: 1, fontSize: compact ? '0.95rem' : '1rem' }} />
|
||||
<Skeleton variant="text" width="60%" sx={{ fontSize: '0.75rem' }} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const HeroCarouselSkeleton: React.FC = () => (
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
height: { xs: 'auto', md: 480 },
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
sx={{
|
||||
width: { xs: '100%', md: '45%' },
|
||||
height: { xs: 240, md: '100%' },
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, p: { xs: 3, md: 6 } }}>
|
||||
<Skeleton variant="text" width="40%" sx={{ fontSize: '0.85rem' }} />
|
||||
<Skeleton variant="text" width="80%" sx={{ fontSize: '2.5rem', mb: 1 }} />
|
||||
<Skeleton variant="text" width="50%" />
|
||||
<Skeleton variant="text" width="100%" sx={{ mt: 3 }} />
|
||||
<Skeleton variant="text" width="90%" />
|
||||
<Skeleton variant="rectangular" width={140} height={42} sx={{ mt: 3, borderRadius: 1 }} />
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const MainPage: React.FC<MainPageProps> = (_props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [selectedCategorySlug, setSelectedCategorySlug] = useState<string>('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const location = useLocation();
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const query = new URLSearchParams(location.search).get('q') || '';
|
||||
const isLoggedIn = !!localStorage.getItem('access');
|
||||
|
||||
const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query);
|
||||
const {
|
||||
data: topFlavors = [],
|
||||
isLoading: topLoading,
|
||||
isFetching: topFetching,
|
||||
} = useTopFlavors(selectedCategorySlug || null);
|
||||
const { data: topFlavorsAll = [], isLoading: topLoading } = useTopFlavors(null);
|
||||
const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors();
|
||||
const { data: recentReviews = [], isLoading: recentLoading } = useRecentReviews();
|
||||
const { data: categories = [], isLoading: catLoading } = useCategories();
|
||||
const { data: feedRatings = [] } = useFollowingFeedTop(isLoggedIn && !query);
|
||||
|
||||
const allFlavors = searchResults;
|
||||
const loading = query
|
||||
? searchLoading
|
||||
: topLoading || newestLoading || recentLoading || catLoading;
|
||||
const categoryLoading = topFetching && !topLoading;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [selectedCategorySlug]);
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => {
|
||||
setSelectedCategorySlug(newValue);
|
||||
};
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % topFlavors.length);
|
||||
}, 6000);
|
||||
}, [topFlavors.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (topFlavors.length > 0 && !query) {
|
||||
startTimer();
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}
|
||||
}, [topFlavors, query, startTimer]);
|
||||
const topFlavors = topFlavorsAll.slice(0, TOP_LIMIT);
|
||||
const safeIndex = topFlavors.length > 0 ? activeIndex % topFlavors.length : 0;
|
||||
const currentTop = topFlavors[safeIndex];
|
||||
const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null;
|
||||
|
||||
useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive');
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (topFlavors.length === 0) return;
|
||||
setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length);
|
||||
startTimer();
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (topFlavors.length === 0) return;
|
||||
setActiveIndex((prev) => (prev + 1) % topFlavors.length);
|
||||
startTimer();
|
||||
};
|
||||
|
||||
// --- SEARCH VIEW ---
|
||||
if (query) {
|
||||
const filteredFlavors = allFlavors.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
f.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||
f.category_name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<SectionHeader
|
||||
title={`Search Results for "${query}"`}
|
||||
subtitle={`Found ${filteredFlavors.length} items`}
|
||||
title={t('home.searchResultsTitle', { query })}
|
||||
subtitle={
|
||||
searchLoading
|
||||
? t('common.loading')
|
||||
: t('home.searchResultsCount', { count: searchResults.length })
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{filteredFlavors.map((flavor) => (
|
||||
<FlavorCard key={flavor.id} flavor={flavor} />
|
||||
))}
|
||||
</Box>
|
||||
{searchLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<FlavorCardSkeleton key={i} />
|
||||
))}
|
||||
</Box>
|
||||
) : searchResults.length === 0 ? (
|
||||
<EmptyState title={t('home.noSearchResults', { query })} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{searchResults.map((flavor) => (
|
||||
<FlavorCard key={flavor.id} flavor={flavor} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// --- HOME VIEW ---
|
||||
const currentTop = topFlavors[activeIndex];
|
||||
const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null;
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant={isLoggedIn ? 'minimal' : 'mesh'} />}>
|
||||
{/* Hero / Activity Feed */}
|
||||
{/* Hero */}
|
||||
{isLoggedIn ? (
|
||||
<Box>
|
||||
<SectionHeader
|
||||
title={t('home.activity')}
|
||||
subtitle={t('home.activitySubtitle')}
|
||||
action={
|
||||
<Button
|
||||
component={Link}
|
||||
to="/community"
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 2, textTransform: 'none' }}
|
||||
>
|
||||
{t('home.viewFullFeed')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedRatings.length === 0 ? (
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '2px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{t('home.quietFeed')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('home.followMore')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to="/"
|
||||
size="small"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('home.exploreFlavors')}
|
||||
</Button>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{feedRatings.map((rating) => (
|
||||
<Box key={rating.id} sx={{ position: 'relative' }}>
|
||||
<ReviewRow review={rating} compact />
|
||||
{adminMode && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/admin-panel/rating/${rating.id}`);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
borderRadius: 1,
|
||||
fontSize: '0.6rem',
|
||||
py: 0,
|
||||
textTransform: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{t('admin.manageRating')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
letterSpacing: '-0.02em',
|
||||
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' },
|
||||
background: (theme) => theme.tokens.accent.gradient,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{t('home.archiveTitle')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 640, lineHeight: 1.6 }}
|
||||
>
|
||||
{t('home.archiveSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
sx={{
|
||||
p: { xs: 4, md: 10 },
|
||||
p: { xs: 4, md: 8 },
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
@@ -247,8 +204,8 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
component="h1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '2.2rem', sm: '3rem', md: '4.5rem' },
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '2.2rem', sm: '3rem', md: '4rem' },
|
||||
overflowWrap: 'break-word',
|
||||
background: (theme) => theme.tokens.accent.gradient,
|
||||
WebkitBackgroundClip: 'text',
|
||||
@@ -259,9 +216,9 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
{t('hero.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 4, maxWidth: 800, mx: 'auto', px: 2 }}
|
||||
sx={{ mb: 4, maxWidth: 720, mx: 'auto', px: 2, lineHeight: 1.5 }}
|
||||
>
|
||||
{t('hero.subtitle')}
|
||||
</Typography>
|
||||
@@ -288,296 +245,245 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', xl: 'row' }, gap: 6 }}>
|
||||
{/* Hall of Fame Carousel */}
|
||||
{topFlavors.length > 0 && currentTop && (
|
||||
<Box sx={{ flex: { xs: '1 1 100%', xl: '1 1 65%' }, minWidth: 0 }}>
|
||||
<SectionHeader
|
||||
title={
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||
<EmojiEventsIcon color="primary" sx={{ fontSize: '2rem' }} />
|
||||
<span>{t('home.hallOfFame')}</span>
|
||||
</Box>
|
||||
}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton
|
||||
onClick={handlePrev}
|
||||
sx={{ border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNext}
|
||||
sx={{ border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
|
||||
<Tabs
|
||||
value={selectedCategorySlug}
|
||||
onChange={handleTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="hall of fame categories"
|
||||
sx={{ '& .MuiTab-root': { fontWeight: 'bold', minWidth: 100 } }}
|
||||
>
|
||||
<Tab label="All Flavors" value="" />
|
||||
{categories.map((cat) => (
|
||||
<Tab key={cat.id} label={cat.name} value={cat.slug} />
|
||||
))}
|
||||
</Tabs>
|
||||
{/* Hall of Fame */}
|
||||
<Box>
|
||||
<SectionHeader
|
||||
title={
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||
<EmojiEventsIcon color="primary" sx={{ fontSize: '1.75rem' }} />
|
||||
<span>{t('home.hallOfFame')}</span>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ position: 'relative', minHeight: { xs: 400, md: 500 } }}>
|
||||
{categoryLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
bgcolor: (theme) => theme.tokens.glass.tint,
|
||||
backdropFilter: (theme) => theme.tokens.glass.blur,
|
||||
zIndex: 2,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
}
|
||||
subtitle={t('home.hallOfFameSubtitle')}
|
||||
action={
|
||||
topFlavors.length > 1 ? (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton
|
||||
onClick={handlePrev}
|
||||
aria-label={t('home.previousFlavor')}
|
||||
sx={{ border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNext}
|
||||
aria-label={t('home.nextFlavor')}
|
||||
sx={{ border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{topLoading ? (
|
||||
<HeroCarouselSkeleton />
|
||||
) : currentTop ? (
|
||||
<>
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
height: { xs: 'auto', md: 480 },
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/flavor/${currentTop.id}`}
|
||||
sx={{
|
||||
width: { xs: '100%', md: '45%' },
|
||||
height: { md: '100%' },
|
||||
aspectRatio: { xs: '16/9', md: 'auto' },
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
height: { xs: 'auto', md: 500 },
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/flavor/${currentTop.id}`}
|
||||
sx={{
|
||||
width: { xs: '100%', md: '45%' },
|
||||
height: { md: '100%' },
|
||||
aspectRatio: { xs: '16/9', md: 'auto' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={currentTop.is_legacy}
|
||||
isAvailable={currentTop.is_available}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={currentTop.is_legacy}
|
||||
isAvailable={currentTop.is_available}
|
||||
/>
|
||||
</Box>
|
||||
{currentTop.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={currentTop.image_url || ''}
|
||||
src={currentTop.image_url}
|
||||
alt={currentTop.name}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
<CardContent
|
||||
)}
|
||||
</Box>
|
||||
<CardContent
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 3, sm: 4, md: 6 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={`${t('common.rank')} #${safeIndex + 1}`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h3"
|
||||
gutterBottom
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: { xs: 3, sm: 4, md: 6 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' },
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={`${t('common.rank')} #${activeIndex + 1}`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h3"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '1.75rem', sm: '2.5rem', md: '3rem' },
|
||||
}}
|
||||
>
|
||||
{currentTop.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: { xs: 2, md: 3 },
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<RatingBadge score={currentTop.average_rating || 0} size="large" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{currentTop.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{featuredReview && (
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{ mt: 1, p: { xs: 2, md: 3 }, boxShadow: 'none' }}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontStyle: 'italic',
|
||||
mb: 1,
|
||||
fontSize: { xs: '0.9rem', md: '1.1rem' },
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 3,
|
||||
}}
|
||||
>
|
||||
"{featuredReview.comment}"
|
||||
</Typography>
|
||||
<Link
|
||||
to={`/profile/${featuredReview.user}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={featuredReview.user_avatar || undefined}
|
||||
sx={{ width: 24, height: 24, fontSize: '0.6rem' }}
|
||||
>
|
||||
{!featuredReview.user_avatar && featuredReview.user.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
— {featuredReview.user}
|
||||
</Typography>
|
||||
</Link>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to={`/flavor/${currentTop.id}`}
|
||||
sx={{
|
||||
mt: { xs: 3, md: 4 },
|
||||
width: { xs: '100%', sm: 'fit-content' },
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{t('home.viewAllReviews')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3, gap: 1 }}>
|
||||
{topFlavors.map((_, i) => (
|
||||
{currentTop.name}
|
||||
</Typography>
|
||||
<Box
|
||||
key={i}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
sx={{
|
||||
width: activeIndex === i ? 24 : 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: activeIndex === i ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: { xs: 2, md: 3 },
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<RatingBadge score={currentTop.average_rating || 0} size="large" />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{currentTop.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Recent Reviews */}
|
||||
{recentReviews.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
flex: { xs: '1 1 100%', xl: '1 1 35%' },
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<SectionHeader title={t('home.communityVoice')} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
height: { xl: 500 },
|
||||
overflowY: { xl: 'auto' },
|
||||
pr: { xl: 1 },
|
||||
'&::-webkit-scrollbar': { width: '6px' },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: '3px' },
|
||||
}}
|
||||
>
|
||||
{recentReviews.map((review) => (
|
||||
<Box key={review.id} sx={{ position: 'relative' }}>
|
||||
<ReviewRow review={review} compact />
|
||||
{adminMode && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/admin-panel/rating/${review.id}`);
|
||||
}}
|
||||
{featuredReview && (
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{ mt: 1, p: { xs: 2, md: 2.5 }, boxShadow: 'none' }}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
fontSize: '0.65rem',
|
||||
py: 0,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 2,
|
||||
fontStyle: 'italic',
|
||||
mb: 1,
|
||||
fontSize: { xs: '0.9rem', md: '1rem' },
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 3,
|
||||
}}
|
||||
>
|
||||
{t('admin.manageRating')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Button
|
||||
component={Link}
|
||||
to="/community"
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{ alignSelf: 'center', mt: 1 }}
|
||||
>
|
||||
{t('home.viewMoreReviews')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
"{featuredReview.comment}"
|
||||
</Typography>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/profile/${featuredReview.user}`}
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={featuredReview.user_avatar || undefined}
|
||||
sx={{ width: 22, height: 22, fontSize: '0.6rem' }}
|
||||
>
|
||||
{!featuredReview.user_avatar && featuredReview.user.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
— {featuredReview.user}
|
||||
</Typography>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to={`/flavor/${currentTop.id}`}
|
||||
sx={{
|
||||
mt: { xs: 3, md: 4 },
|
||||
width: { xs: '100%', sm: 'fit-content' },
|
||||
px: 4,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{t('home.viewAllReviews')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
|
||||
{topFlavors.length > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3, gap: 1 }}>
|
||||
{topFlavors.map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
component="button"
|
||||
onClick={() => setActiveIndex(i)}
|
||||
aria-label={t('home.goToFlavor', { n: i + 1 })}
|
||||
aria-current={safeIndex === i ? 'true' : undefined}
|
||||
sx={{
|
||||
width: safeIndex === i ? 24 : 8,
|
||||
height: 8,
|
||||
p: 0,
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
bgcolor: safeIndex === i ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.main',
|
||||
outlineOffset: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title={t('home.noTopFlavors')} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Newest Arrivals */}
|
||||
{newestFlavors.length > 0 && (
|
||||
<Box>
|
||||
<SectionHeader title={t('home.newArrivals')} />
|
||||
<Box>
|
||||
<SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} />
|
||||
{newestLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: 'repeat(2, minmax(0, 1fr))',
|
||||
sm: 'repeat(3, minmax(0, 1fr))',
|
||||
md: 'repeat(4, minmax(0, 1fr))',
|
||||
lg: 'repeat(5, minmax(0, 1fr))',
|
||||
xl: 'repeat(6, minmax(0, 1fr))',
|
||||
},
|
||||
gap: 2,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<FlavorCardSkeleton key={i} compact />
|
||||
))}
|
||||
</Box>
|
||||
) : newestFlavors.length === 0 ? (
|
||||
<EmptyState title={t('home.noNewest')} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
@@ -597,8 +503,8 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
<FlavorCard key={flavor.id} flavor={flavor} compact />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user