mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
Typography,
|
|
Box,
|
|
CardContent,
|
|
Button,
|
|
Avatar,
|
|
Chip,
|
|
IconButton,
|
|
Skeleton,
|
|
} from '@mui/material';
|
|
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 {
|
|
useFlavorSearch,
|
|
useNewestFlavors,
|
|
useTopFlavors,
|
|
} from '../api/queries/useMainPageQueries';
|
|
import { useTitle } from '../hooks/useTitle';
|
|
import RatingBadge from '../components/RatingBadge';
|
|
import StatusBadge from '../components/StatusBadge';
|
|
import {
|
|
GlassCard,
|
|
PageShell,
|
|
SectionHeader,
|
|
HeroBackdrop,
|
|
FlavorCard,
|
|
EmptyState,
|
|
} from '../components/ui';
|
|
|
|
interface MainPageProps {
|
|
adminMode?: boolean;
|
|
}
|
|
|
|
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 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: topFlavorsAll = [], isLoading: topLoading } = useTopFlavors(null);
|
|
const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors();
|
|
|
|
const topFlavors = topFlavorsAll
|
|
.filter((f) => f.category_slug !== 'packs-and-other')
|
|
.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');
|
|
|
|
const handlePrev = () => {
|
|
if (topFlavors.length === 0) return;
|
|
setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length);
|
|
};
|
|
const handleNext = () => {
|
|
if (topFlavors.length === 0) return;
|
|
setActiveIndex((prev) => (prev + 1) % topFlavors.length);
|
|
};
|
|
|
|
// --- SEARCH VIEW ---
|
|
if (query) {
|
|
return (
|
|
<PageShell>
|
|
<SectionHeader
|
|
title={t('home.searchResultsTitle', { query })}
|
|
subtitle={
|
|
searchLoading
|
|
? t('common.loading')
|
|
: t('home.searchResultsCount', { count: searchResults.length })
|
|
}
|
|
/>
|
|
{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 ---
|
|
return (
|
|
<PageShell hero={<HeroBackdrop variant={isLoggedIn ? 'minimal' : 'mesh'} />}>
|
|
{/* Hero */}
|
|
{isLoggedIn ? (
|
|
<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: 8 },
|
|
textAlign: 'center',
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="h2"
|
|
component="h1"
|
|
gutterBottom
|
|
sx={{
|
|
fontWeight: 800,
|
|
fontSize: { xs: '2.2rem', sm: '3rem', md: '4rem' },
|
|
overflowWrap: 'break-word',
|
|
background: (theme) => theme.tokens.accent.gradient,
|
|
WebkitBackgroundClip: 'text',
|
|
WebkitTextFillColor: 'transparent',
|
|
backgroundClip: 'text',
|
|
}}
|
|
>
|
|
{t('hero.title')}
|
|
</Typography>
|
|
<Typography
|
|
variant="h6"
|
|
color="text.secondary"
|
|
sx={{ mb: 4, maxWidth: 720, mx: 'auto', px: 2, lineHeight: 1.5 }}
|
|
>
|
|
{t('hero.subtitle')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, flexWrap: 'wrap', px: 2 }}>
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
component={Link}
|
|
to="/login"
|
|
sx={{ px: 4, py: 1.5, borderRadius: 2 }}
|
|
>
|
|
{t('hero.join')}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
size="large"
|
|
component={Link}
|
|
to="/about"
|
|
sx={{ px: 4, py: 1.5, borderRadius: 2 }}
|
|
>
|
|
{t('hero.whatIsThis')}
|
|
</Button>
|
|
</Box>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{/* 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>
|
|
}
|
|
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' }}
|
|
>
|
|
<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',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'action.hover',
|
|
overflow: 'hidden',
|
|
textDecoration: 'none',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<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}
|
|
alt={currentTop.name}
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
loading="lazy"
|
|
/>
|
|
)}
|
|
</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={{
|
|
fontWeight: 800,
|
|
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' },
|
|
lineHeight: 1.1,
|
|
}}
|
|
>
|
|
{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: 2.5 }, boxShadow: 'none' }}
|
|
>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontStyle: 'italic',
|
|
mb: 1,
|
|
fontSize: { xs: '0.9rem', md: '1rem' },
|
|
lineHeight: 1.5,
|
|
display: '-webkit-box',
|
|
overflow: 'hidden',
|
|
WebkitBoxOrient: 'vertical',
|
|
WebkitLineClamp: 3,
|
|
}}
|
|
>
|
|
"{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 */}
|
|
<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',
|
|
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%',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{newestFlavors.map((flavor) => (
|
|
<FlavorCard key={flavor.id} flavor={flavor} compact />
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</PageShell>
|
|
);
|
|
};
|
|
|
|
export default MainPage;
|