From 6f5f3eaf37d4e72eca9ad3f699df7fc4e9357f49 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 26 Apr 2026 13:35:32 +0200 Subject: [PATCH] mainpage redesign --- frontend/src/i18n.ts | 28 ++ frontend/src/pages/MainPage.tsx | 780 ++++++++++++++------------------ 2 files changed, 371 insertions(+), 437 deletions(-) diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 7d6b08f..7eb82fe 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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...', diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 19ffbdf..dd9460d 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -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 = ({ adminMode }) => { +const TOP_LIMIT = 5; + +const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => ( + + + + + +); + +const HeroCarouselSkeleton: React.FC = () => ( + + + + + + + + + + + +); + +const MainPage: React.FC = (_props) => { const { t } = useTranslation(); - const navigate = useNavigate(); - const [selectedCategorySlug, setSelectedCategorySlug] = useState(''); - const [activeIndex, setActiveIndex] = useState(0); - const timerRef = useRef | 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 ( - - - - ); - 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 ( - - {filteredFlavors.map((flavor) => ( - - ))} - + {searchLoading ? ( + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ) : searchResults.length === 0 ? ( + + ) : ( + + {searchResults.map((flavor) => ( + + ))} + + )} ); } // --- HOME VIEW --- - const currentTop = topFlavors[activeIndex]; - const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null; - return ( }> - {/* Hero / Activity Feed */} + {/* Hero */} {isLoggedIn ? ( - - - {t('home.viewFullFeed')} - - } - /> - - {feedRatings.length === 0 ? ( - - {t('home.quietFeed')} - - {t('home.followMore')} - - - - ) : ( - - {feedRatings.map((rating) => ( - - - {adminMode && ( - - )} - - ))} - - )} + + theme.tokens.accent.gradient, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', + mb: 1, + }} + > + {t('home.archiveTitle')} + + + {t('home.archiveSubtitle')} + ) : ( = ({ 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 = ({ adminMode }) => { {t('hero.title')} {t('hero.subtitle')} @@ -288,296 +245,245 @@ const MainPage: React.FC = ({ adminMode }) => { )} - - {/* Hall of Fame Carousel */} - {topFlavors.length > 0 && currentTop && ( - - - - {t('home.hallOfFame')} - - } - action={ - - - - - - - - - } - /> - - - - - {categories.map((cat) => ( - - ))} - + {/* Hall of Fame */} + + + + {t('home.hallOfFame')} - - - {categoryLoading && ( - theme.tokens.glass.tint, - backdropFilter: (theme) => theme.tokens.glass.blur, - zIndex: 2, - borderRadius: 4, - }} + } + subtitle={t('home.hallOfFameSubtitle')} + action={ + topFlavors.length > 1 ? ( + + - - - )} - + + + + + + ) : undefined + } + /> + + {topLoading ? ( + + ) : currentTop ? ( + <> + + - - - - + + + + {currentTop.image_url && ( - - + + + - - - {currentTop.name} - - - - - {currentTop.ratings.length} {t('common.reviews')} - - - - {featuredReview && ( - - - "{featuredReview.comment}" - - - - {!featuredReview.user_avatar && featuredReview.user.charAt(0)} - - - — {featuredReview.user} - - - - )} - - - - - - - - {topFlavors.map((_, i) => ( + {currentTop.name} + 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, }} - /> - ))} - - - )} + > + + + {currentTop.ratings.length} {t('common.reviews')} + + - {/* Recent Reviews */} - {recentReviews.length > 0 && ( - - - - {recentReviews.map((review) => ( - - - {adminMode && ( - - )} - - ))} - - - + "{featuredReview.comment}" + + + + {!featuredReview.user_avatar && featuredReview.user.charAt(0)} + + + — {featuredReview.user} + + + + )} + + + + + + {topFlavors.length > 1 && ( + + {topFlavors.map((_, i) => ( + 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, + }, + }} + /> + ))} + + )} + + ) : ( + )} {/* Newest Arrivals */} - {newestFlavors.length > 0 && ( - - + + + {newestLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ) : newestFlavors.length === 0 ? ( + + ) : ( = ({ adminMode }) => { ))} - - )} + )} + ); };