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!',
|
followMore: 'Follow more users to see their activity here!',
|
||||||
exploreFlavors: 'Explore Flavors',
|
exploreFlavors: 'Explore Flavors',
|
||||||
hallOfFame: 'Hall of Fame',
|
hallOfFame: 'Hall of Fame',
|
||||||
|
hallOfFameSubtitle: 'Highest-rated flavors in the archive',
|
||||||
communityVoice: 'Community Voice',
|
communityVoice: 'Community Voice',
|
||||||
newArrivals: 'New Arrivals',
|
newArrivals: 'New Arrivals',
|
||||||
|
newArrivalsSubtitle: 'The latest additions to the catalog',
|
||||||
viewAllReviews: 'View All Reviews',
|
viewAllReviews: 'View All Reviews',
|
||||||
viewMoreReviews: 'View more reviews',
|
viewMoreReviews: 'View more reviews',
|
||||||
rated: 'rated',
|
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: {
|
common: {
|
||||||
search: 'Search...',
|
search: 'Search...',
|
||||||
@@ -382,11 +396,25 @@ const resources = {
|
|||||||
followMore: 'Folge mehr Benutzern, um deren Aktivitäten hier zu sehen!',
|
followMore: 'Folge mehr Benutzern, um deren Aktivitäten hier zu sehen!',
|
||||||
exploreFlavors: 'Sorten entdecken',
|
exploreFlavors: 'Sorten entdecken',
|
||||||
hallOfFame: 'Hall of Fame',
|
hallOfFame: 'Hall of Fame',
|
||||||
|
hallOfFameSubtitle: 'Die bestbewerteten Sorten im Archiv',
|
||||||
communityVoice: 'Community-Stimmen',
|
communityVoice: 'Community-Stimmen',
|
||||||
newArrivals: 'Neuheiten',
|
newArrivals: 'Neuheiten',
|
||||||
|
newArrivalsSubtitle: 'Die neuesten Zugänge im Katalog',
|
||||||
viewAllReviews: 'Alle Bewertungen ansehen',
|
viewAllReviews: 'Alle Bewertungen ansehen',
|
||||||
viewMoreReviews: 'Mehr Bewertungen',
|
viewMoreReviews: 'Mehr Bewertungen',
|
||||||
rated: 'bewertete',
|
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: {
|
common: {
|
||||||
search: 'Suchen...',
|
search: 'Suchen...',
|
||||||
|
|||||||
+343
-437
@@ -1,27 +1,22 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
CircularProgress,
|
|
||||||
Button,
|
Button,
|
||||||
Avatar,
|
Avatar,
|
||||||
Chip,
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tabs,
|
Skeleton,
|
||||||
Tab,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||||
import { useCategories } from '../api/queries/useFlavorsList';
|
|
||||||
import {
|
import {
|
||||||
useFlavorSearch,
|
useFlavorSearch,
|
||||||
useFollowingFeedTop,
|
|
||||||
useNewestFlavors,
|
useNewestFlavors,
|
||||||
useRecentReviews,
|
|
||||||
useTopFlavors,
|
useTopFlavors,
|
||||||
} from '../api/queries/useMainPageQueries';
|
} from '../api/queries/useMainPageQueries';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
@@ -32,211 +27,173 @@ import {
|
|||||||
PageShell,
|
PageShell,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
HeroBackdrop,
|
HeroBackdrop,
|
||||||
ReviewRow,
|
|
||||||
FlavorCard,
|
FlavorCard,
|
||||||
|
EmptyState,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
|
|
||||||
interface MainPageProps {
|
interface MainPageProps {
|
||||||
adminMode?: boolean;
|
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 { 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 location = useLocation();
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
const query = new URLSearchParams(location.search).get('q') || '';
|
const query = new URLSearchParams(location.search).get('q') || '';
|
||||||
const isLoggedIn = !!localStorage.getItem('access');
|
const isLoggedIn = !!localStorage.getItem('access');
|
||||||
|
|
||||||
const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query);
|
const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query);
|
||||||
const {
|
const { data: topFlavorsAll = [], isLoading: topLoading } = useTopFlavors(null);
|
||||||
data: topFlavors = [],
|
|
||||||
isLoading: topLoading,
|
|
||||||
isFetching: topFetching,
|
|
||||||
} = useTopFlavors(selectedCategorySlug || null);
|
|
||||||
const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors();
|
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 topFlavors = topFlavorsAll.slice(0, TOP_LIMIT);
|
||||||
const loading = query
|
const safeIndex = topFlavors.length > 0 ? activeIndex % topFlavors.length : 0;
|
||||||
? searchLoading
|
const currentTop = topFlavors[safeIndex];
|
||||||
: topLoading || newestLoading || recentLoading || catLoading;
|
const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null;
|
||||||
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]);
|
|
||||||
|
|
||||||
useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive');
|
useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive');
|
||||||
|
|
||||||
if (loading)
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = () => {
|
||||||
|
if (topFlavors.length === 0) return;
|
||||||
setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length);
|
setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length);
|
||||||
startTimer();
|
|
||||||
};
|
};
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
|
if (topFlavors.length === 0) return;
|
||||||
setActiveIndex((prev) => (prev + 1) % topFlavors.length);
|
setActiveIndex((prev) => (prev + 1) % topFlavors.length);
|
||||||
startTimer();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- SEARCH VIEW ---
|
// --- SEARCH VIEW ---
|
||||||
if (query) {
|
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={`Search Results for "${query}"`}
|
title={t('home.searchResultsTitle', { query })}
|
||||||
subtitle={`Found ${filteredFlavors.length} items`}
|
subtitle={
|
||||||
|
searchLoading
|
||||||
|
? t('common.loading')
|
||||||
|
: t('home.searchResultsCount', { count: searchResults.length })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Box
|
{searchLoading ? (
|
||||||
sx={{
|
<Box
|
||||||
display: 'grid',
|
sx={{
|
||||||
gridTemplateColumns: {
|
display: 'grid',
|
||||||
xs: '1fr',
|
gridTemplateColumns: {
|
||||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
xs: '1fr',
|
||||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
},
|
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
gap: 3,
|
},
|
||||||
}}
|
gap: 3,
|
||||||
>
|
}}
|
||||||
{filteredFlavors.map((flavor) => (
|
>
|
||||||
<FlavorCard key={flavor.id} flavor={flavor} />
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
))}
|
<FlavorCardSkeleton key={i} />
|
||||||
</Box>
|
))}
|
||||||
|
</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>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HOME VIEW ---
|
// --- HOME VIEW ---
|
||||||
const currentTop = topFlavors[activeIndex];
|
|
||||||
const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell hero={<HeroBackdrop variant={isLoggedIn ? 'minimal' : 'mesh'} />}>
|
<PageShell hero={<HeroBackdrop variant={isLoggedIn ? 'minimal' : 'mesh'} />}>
|
||||||
{/* Hero / Activity Feed */}
|
{/* Hero */}
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<Box>
|
<Box sx={{ mb: 1 }}>
|
||||||
<SectionHeader
|
<Typography
|
||||||
title={t('home.activity')}
|
variant="h3"
|
||||||
subtitle={t('home.activitySubtitle')}
|
component="h1"
|
||||||
action={
|
sx={{
|
||||||
<Button
|
fontWeight: 800,
|
||||||
component={Link}
|
letterSpacing: '-0.02em',
|
||||||
to="/community"
|
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' },
|
||||||
variant="outlined"
|
background: (theme) => theme.tokens.accent.gradient,
|
||||||
sx={{ borderRadius: 2, textTransform: 'none' }}
|
WebkitBackgroundClip: 'text',
|
||||||
>
|
WebkitTextFillColor: 'transparent',
|
||||||
{t('home.viewFullFeed')}
|
backgroundClip: 'text',
|
||||||
</Button>
|
mb: 1,
|
||||||
}
|
}}
|
||||||
/>
|
>
|
||||||
|
{t('home.archiveTitle')}
|
||||||
{feedRatings.length === 0 ? (
|
</Typography>
|
||||||
<GlassCard
|
<Typography
|
||||||
intensity="subtle"
|
variant="body1"
|
||||||
sx={{
|
color="text.secondary"
|
||||||
p: 4,
|
sx={{ maxWidth: 640, lineHeight: 1.6 }}
|
||||||
textAlign: 'center',
|
>
|
||||||
border: '2px dashed',
|
{t('home.archiveSubtitle')}
|
||||||
borderColor: 'divider',
|
</Typography>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<GlassCard
|
<GlassCard
|
||||||
intensity="strong"
|
intensity="strong"
|
||||||
sx={{
|
sx={{
|
||||||
p: { xs: 4, md: 10 },
|
p: { xs: 4, md: 8 },
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -247,8 +204,8 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
|||||||
component="h1"
|
component="h1"
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 800,
|
||||||
fontSize: { xs: '2.2rem', sm: '3rem', md: '4.5rem' },
|
fontSize: { xs: '2.2rem', sm: '3rem', md: '4rem' },
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
background: (theme) => theme.tokens.accent.gradient,
|
background: (theme) => theme.tokens.accent.gradient,
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: 'text',
|
||||||
@@ -259,9 +216,9 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
|||||||
{t('hero.title')}
|
{t('hero.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h5"
|
variant="h6"
|
||||||
color="text.secondary"
|
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')}
|
{t('hero.subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -288,296 +245,245 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', xl: 'row' }, gap: 6 }}>
|
{/* Hall of Fame */}
|
||||||
{/* Hall of Fame Carousel */}
|
<Box>
|
||||||
{topFlavors.length > 0 && currentTop && (
|
<SectionHeader
|
||||||
<Box sx={{ flex: { xs: '1 1 100%', xl: '1 1 65%' }, minWidth: 0 }}>
|
title={
|
||||||
<SectionHeader
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||||
title={
|
<EmojiEventsIcon color="primary" sx={{ fontSize: '1.75rem' }} />
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
<span>{t('home.hallOfFame')}</span>
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
}
|
||||||
<Box sx={{ position: 'relative', minHeight: { xs: 400, md: 500 } }}>
|
subtitle={t('home.hallOfFameSubtitle')}
|
||||||
{categoryLoading && (
|
action={
|
||||||
<Box
|
topFlavors.length > 1 ? (
|
||||||
sx={{
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
position: 'absolute',
|
<IconButton
|
||||||
inset: 0,
|
onClick={handlePrev}
|
||||||
display: 'flex',
|
aria-label={t('home.previousFlavor')}
|
||||||
justifyContent: 'center',
|
sx={{ border: '1px solid', borderColor: 'divider' }}
|
||||||
alignItems: 'center',
|
|
||||||
bgcolor: (theme) => theme.tokens.glass.tint,
|
|
||||||
backdropFilter: (theme) => theme.tokens.glass.blur,
|
|
||||||
zIndex: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CircularProgress />
|
<ChevronLeftIcon />
|
||||||
</Box>
|
</IconButton>
|
||||||
)}
|
<IconButton
|
||||||
<GlassCard
|
onClick={handleNext}
|
||||||
intensity="strong"
|
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={{
|
sx={{
|
||||||
|
width: { xs: '100%', md: '45%' },
|
||||||
|
height: { md: '100%' },
|
||||||
|
aspectRatio: { xs: '16/9', md: 'auto' },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: { xs: 'column', md: 'row' },
|
alignItems: 'center',
|
||||||
height: { xs: 'auto', md: 500 },
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
textDecoration: 'none',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||||
component={Link}
|
<StatusBadge
|
||||||
to={`/flavor/${currentTop.id}`}
|
isLegacy={currentTop.is_legacy}
|
||||||
sx={{
|
isAvailable={currentTop.is_available}
|
||||||
width: { xs: '100%', md: '45%' },
|
/>
|
||||||
height: { md: '100%' },
|
</Box>
|
||||||
aspectRatio: { xs: '16/9', md: 'auto' },
|
{currentTop.image_url && (
|
||||||
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
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={currentTop.image_url || ''}
|
src={currentTop.image_url}
|
||||||
|
alt={currentTop.name}
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
loading="lazy"
|
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={{
|
sx={{
|
||||||
flex: 1,
|
fontWeight: 800,
|
||||||
p: { xs: 3, sm: 4, md: 6 },
|
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' },
|
||||||
display: 'flex',
|
lineHeight: 1.1,
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
{currentTop.name}
|
||||||
label={`${t('common.rank')} #${activeIndex + 1}`}
|
</Typography>
|
||||||
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) => (
|
|
||||||
<Box
|
<Box
|
||||||
key={i}
|
|
||||||
onClick={() => setActiveIndex(i)}
|
|
||||||
sx={{
|
sx={{
|
||||||
width: activeIndex === i ? 24 : 8,
|
display: 'flex',
|
||||||
height: 8,
|
alignItems: 'center',
|
||||||
borderRadius: 4,
|
mb: { xs: 2, md: 3 },
|
||||||
bgcolor: activeIndex === i ? 'primary.main' : 'divider',
|
flexWrap: 'wrap',
|
||||||
cursor: 'pointer',
|
gap: 2,
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
))}
|
<RatingBadge score={currentTop.average_rating || 0} size="large" />
|
||||||
</Box>
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||||
</Box>
|
{currentTop.ratings.length} {t('common.reviews')}
|
||||||
)}
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Recent Reviews */}
|
{featuredReview && (
|
||||||
{recentReviews.length > 0 && (
|
<GlassCard
|
||||||
<Box
|
intensity="subtle"
|
||||||
sx={{
|
sx={{ mt: 1, p: { xs: 2, md: 2.5 }, boxShadow: 'none' }}
|
||||||
flex: { xs: '1 1 100%', xl: '1 1 35%' },
|
>
|
||||||
minWidth: 0,
|
<Typography
|
||||||
display: 'flex',
|
variant="body2"
|
||||||
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}`);
|
|
||||||
}}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
fontStyle: 'italic',
|
||||||
top: 4,
|
mb: 1,
|
||||||
right: 4,
|
fontSize: { xs: '0.9rem', md: '1rem' },
|
||||||
fontSize: '0.65rem',
|
lineHeight: 1.5,
|
||||||
py: 0,
|
display: '-webkit-box',
|
||||||
textTransform: 'none',
|
overflow: 'hidden',
|
||||||
fontWeight: 'bold',
|
WebkitBoxOrient: 'vertical',
|
||||||
zIndex: 2,
|
WebkitLineClamp: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('admin.manageRating')}
|
"{featuredReview.comment}"
|
||||||
</Button>
|
</Typography>
|
||||||
)}
|
<Box
|
||||||
</Box>
|
component={Link}
|
||||||
))}
|
to={`/profile/${featuredReview.user}`}
|
||||||
<Button
|
sx={{
|
||||||
component={Link}
|
textDecoration: 'none',
|
||||||
to="/community"
|
color: 'inherit',
|
||||||
variant="text"
|
display: 'flex',
|
||||||
size="small"
|
alignItems: 'center',
|
||||||
sx={{ alignSelf: 'center', mt: 1 }}
|
gap: 1,
|
||||||
>
|
}}
|
||||||
{t('home.viewMoreReviews')}
|
>
|
||||||
</Button>
|
<Avatar
|
||||||
</Box>
|
src={featuredReview.user_avatar || undefined}
|
||||||
</Box>
|
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>
|
</Box>
|
||||||
|
|
||||||
{/* Newest Arrivals */}
|
{/* Newest Arrivals */}
|
||||||
{newestFlavors.length > 0 && (
|
<Box>
|
||||||
<Box>
|
<SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} />
|
||||||
<SectionHeader title={t('home.newArrivals')} />
|
{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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -597,8 +503,8 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
|||||||
<FlavorCard key={flavor.id} flavor={flavor} compact />
|
<FlavorCard key={flavor.id} flavor={flavor} compact />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
</Box>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user