mainpage redesign

This commit is contained in:
nvrl committed 2026-04-26 13:35:32 +02:00
1 parent ecedabe8b0
commit 6f5f3eaf37
2 files changed
+210 -276

No files matched your search

+28
View File
@@ -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...',
+182 -276
View File
@@ -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,99 +27,100 @@ 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 })
}
/>
{searchLoading ? (
<Box
sx={{
display: 'grid',
@@ -136,107 +132,68 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
gap: 3,
}}
>
{filteredFlavors.map((flavor) => (
{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"
<Box sx={{ mb: 1 }}>
<Typography
variant="h3"
component="h1"
sx={{
p: 4,
textAlign: 'center',
border: '2px dashed',
borderColor: 'divider',
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,
}}
>
<Typography variant="h6">{t('home.quietFeed')}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('home.followMore')}
{t('home.archiveTitle')}
</Typography>
<Button
variant="contained"
component={Link}
to="/"
size="small"
sx={{ borderRadius: 2 }}
<Typography
variant="body1"
color="text.secondary"
sx={{ maxWidth: 640, lineHeight: 1.6 }}
>
{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>
)}
{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,75 +245,48 @@ 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 }}>
{/* Hall of Fame */}
<Box>
<SectionHeader
title={
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
<EmojiEventsIcon color="primary" sx={{ fontSize: '2rem' }} />
<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
}
/>
<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 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,
}}
>
<CircularProgress />
</Box>
)}
{topLoading ? (
<HeroCarouselSkeleton />
) : currentTop ? (
<>
<GlassCard
intensity="strong"
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
height: { xs: 'auto', md: 500 },
height: { xs: 'auto', md: 480 },
overflow: 'hidden',
position: 'relative',
}}
@@ -374,6 +304,7 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
bgcolor: 'action.hover',
overflow: 'hidden',
textDecoration: 'none',
position: 'relative',
}}
>
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
@@ -382,12 +313,15 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
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
sx={{
@@ -399,7 +333,7 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
}}
>
<Chip
label={`${t('common.rank')} #${activeIndex + 1}`}
label={`${t('common.rank')} #${safeIndex + 1}`}
color="primary"
size="small"
sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }}
@@ -408,8 +342,9 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
variant="h3"
gutterBottom
sx={{
fontWeight: 'bold',
fontSize: { xs: '1.75rem', sm: '2.5rem', md: '3rem' },
fontWeight: 800,
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' },
lineHeight: 1.1,
}}
>
{currentTop.name}
@@ -432,25 +367,27 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
{featuredReview && (
<GlassCard
intensity="subtle"
sx={{ mt: 1, p: { xs: 2, md: 3 }, boxShadow: 'none' }}
sx={{ mt: 1, p: { xs: 2, md: 2.5 }, boxShadow: 'none' }}
>
<Typography
variant="body2"
sx={{
fontStyle: 'italic',
mb: 1,
fontSize: { xs: '0.9rem', md: '1.1rem' },
fontSize: { xs: '0.9rem', md: '1rem' },
lineHeight: 1.5,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3,
}}
>
"{featuredReview.comment}"
&quot;{featuredReview.comment}&quot;
</Typography>
<Link
<Box
component={Link}
to={`/profile/${featuredReview.user}`}
style={{
sx={{
textDecoration: 'none',
color: 'inherit',
display: 'flex',
@@ -460,14 +397,14 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
>
<Avatar
src={featuredReview.user_avatar || undefined}
sx={{ width: 24, height: 24, fontSize: '0.6rem' }}
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>
</Link>
</Box>
</GlassCard>
)}
@@ -479,7 +416,7 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
mt: { xs: 3, md: 4 },
width: { xs: '100%', sm: 'fit-content' },
px: 4,
py: 1.5,
py: 1.25,
borderRadius: 2,
}}
>
@@ -487,97 +424,66 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
</Button>
</CardContent>
</GlassCard>
</Box>
{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: activeIndex === i ? 24 : 8,
width: safeIndex === i ? 24 : 8,
height: 8,
p: 0,
border: 'none',
borderRadius: 4,
bgcolor: activeIndex === i ? 'primary.main' : 'divider',
bgcolor: safeIndex === i ? 'primary.main' : 'divider',
cursor: 'pointer',
transition: 'all 0.3s ease',
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: 2,
},
}}
/>
))}
</Box>
</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}`);
}}
sx={{
position: 'absolute',
top: 4,
right: 4,
fontSize: '0.65rem',
py: 0,
textTransform: 'none',
fontWeight: 'bold',
zIndex: 2,
}}
>
{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>
</>
) : (
<EmptyState title={t('home.noTopFlavors')} />
)}
</Box>
{/* Newest Arrivals */}
{newestFlavors.length > 0 && (
<Box>
<SectionHeader title={t('home.newArrivals')} />
<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>
);
};