From caf3830d720aa8d471a8a901c4033fa2320a2853 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 26 Apr 2026 18:10:54 +0200 Subject: [PATCH] fixed mobile view and dashboard --- frontend/src/App.tsx | 356 +++++++++++++----- .../src/api/queries/useMainPageQueries.ts | 1 + frontend/src/app/GlobalSearch.tsx | 4 +- frontend/src/components/RatingBadge.tsx | 16 +- frontend/src/components/ui/FlavorCard.tsx | 23 +- frontend/src/components/ui/HeroGallery.tsx | 49 +-- .../src/components/ui/RatingDistribution.tsx | 47 +-- frontend/src/i18n.ts | 6 +- frontend/src/pages/Dashboard.tsx | 348 +++++++++++------ frontend/src/pages/MainPage.tsx | 4 +- 10 files changed, 530 insertions(+), 324 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05762a2..4c33a78 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React, { Suspense, lazy, useEffect, useState } from 'react'; -import { Routes, Route, Link, useNavigate } from 'react-router-dom'; +import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'; import { Typography, Button, @@ -8,16 +8,16 @@ import { Avatar, Menu, MenuItem, - Drawer, + SwipeableDrawer, Divider, List, ListItem, ListItemButton, + ListItemIcon, ListItemText, Collapse, CircularProgress, ListItemAvatar, - ListSubheader, Badge, alpha, useMediaQuery, @@ -29,6 +29,18 @@ import NotificationsIcon from '@mui/icons-material/Notifications'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; +import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; +import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined'; +import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; +import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined'; +import CollectionsBookmarkOutlinedIcon from '@mui/icons-material/CollectionsBookmarkOutlined'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; +import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'; +import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; +import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined'; +import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined'; +import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'; import { useTranslation } from 'react-i18next'; import api from './lib/api'; import Footer from './components/Footer'; @@ -61,6 +73,7 @@ const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail')); const App: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const location = useLocation(); const muiTheme = useMuiTheme(); const { user, following, loadingUser, logout } = useAuth(); const { themeName, handleThemeChange } = useTheme(); @@ -72,8 +85,9 @@ const App: React.FC = () => { const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]); const [drawerOpen, setDrawerOpen] = useState(false); const [catOpen, setCatOpen] = useState(false); + const [followingOpen, setFollowingOpen] = useState(false); const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true'); - const isMobile = useMediaQuery('(max-width:1150px)'); + const isMobile = useMediaQuery(muiTheme.breakpoints.down('md')); useEffect(() => { api @@ -115,36 +129,104 @@ const App: React.FC = () => { } }; + const closeDrawer = () => setDrawerOpen(false); + const isActive = (path: string) => + path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); + const drawer = ( - - - - setDrawerOpen(false)} - style={{ color: 'inherit', textDecoration: 'none' }} + + {user ? ( + + - Holy Flavors Archive - - - - - + {user.username.charAt(0).toUpperCase()} + + + + {user.username} + + + {t('nav.profile')} + + + + ) : ( + + + + Holy Flavors Archive + + + + )} + + - setDrawerOpen(false)}> + + + + {user && ( - setDrawerOpen(false)}> + + + + )} - setCatOpen(!catOpen)}> + setCatOpen(!catOpen)} + selected={location.pathname.startsWith('/category/')} + > + + + {catOpen ? : } @@ -155,8 +237,9 @@ const App: React.FC = () => { key={cat.slug} component={Link} to={`/category/${cat.slug}`} - onClick={() => setDrawerOpen(false)} - sx={{ pl: 4 }} + onClick={closeDrawer} + selected={location.pathname === `/category/${cat.slug}`} + sx={{ pl: 7 }} > @@ -164,118 +247,184 @@ const App: React.FC = () => { - - - {user ? ( + {user && ( <> + + setDrawerOpen(false)} + onClick={closeDrawer} + selected={location.pathname === `/profile/${user.username}`} > - - - {user.username.charAt(0).toUpperCase()} - - - {user.username} - - - } - /> + + + + - setDrawerOpen(false)}> + + + + - setDrawerOpen(false)}> + + + + - setDrawerOpen(false)}> + + + + {user.is_superuser && ( - - - - - - )} - - {user.is_superuser && ( - - setDrawerOpen(false)} - > - - - + <> + + + + + + + + + + + + + + + + + + )} {following.length > 0 && ( <> - - {t('nav.following')} - - {following.map((f) => ( - - setDrawerOpen(false)} - > - - - {!f.avatar && f.username.charAt(0).toUpperCase()} - - - - - - ))} + setFollowingOpen(!followingOpen)}> + + + + + {t('nav.following')} + + {following.length} + + + } + /> + {followingOpen ? : } + + + + {following.map((f) => ( + + + + + {!f.avatar && f.username.charAt(0).toUpperCase()} + + + + + + ))} + + )} + + )} + {!user && ( + <> + - - + + + + + - ) : ( - - setDrawerOpen(false)}> - - - )} + + {user && ( + + { + closeDrawer(); + logout(); + }} + > + + + + + + + )} ); @@ -528,9 +677,16 @@ const App: React.FC = () => { - setDrawerOpen(false)}> + setDrawerOpen(true)} + onClose={() => setDrawerOpen(false)} + disableBackdropTransition + disableDiscovery + > {drawer} - + { const { t } = useTranslation(); const navigate = useNavigate(); - const isMobile = useMediaQuery('(max-width:1150px)'); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); const { query, setQuery, options, loading } = useGlobalSearch(); const handleSearch = (e: React.FormEvent) => { diff --git a/frontend/src/components/RatingBadge.tsx b/frontend/src/components/RatingBadge.tsx index 54c4bd0..094b9cf 100644 --- a/frontend/src/components/RatingBadge.tsx +++ b/frontend/src/components/RatingBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Typography, alpha, useTheme } from '@mui/material'; +import { Box, alpha, useTheme } from '@mui/material'; interface RatingBadgeProps { score: number; @@ -42,7 +42,7 @@ const RatingBadge: React.FC = ({ score, size = 'medium', sx }) = ({ score, size = 'medium', sx }) color: theme.palette.primary.contrastText, boxShadow: `0 6px 16px ${alpha(theme.palette.primary.main, 0.28)}, inset 0 1px 0 ${alpha('#ffffff', 0.2)}`, minWidth: 'fit-content', + lineHeight: 1, ...sx, }} > - = ({ score, size = 'medium', sx }) fontSize: dims.score, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em', + display: 'inline-block', }} > {display} - - + = ({ score, size = 'medium', sx }) fontSize: dims.suffix, opacity: 0.75, letterSpacing: '0.02em', + display: 'inline-block', + mt: '1px', }} > / 10 - + ); }; diff --git a/frontend/src/components/ui/FlavorCard.tsx b/frontend/src/components/ui/FlavorCard.tsx index 9d037f4..92af02d 100644 --- a/frontend/src/components/ui/FlavorCard.tsx +++ b/frontend/src/components/ui/FlavorCard.tsx @@ -46,8 +46,7 @@ export const FlavorCard = ({ flexDirection: 'column', position: 'relative', overflow: 'hidden', - transition: - 'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease', + transition: 'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease', '&:hover': { borderColor: alpha(theme.palette.primary.main, 0.5), boxShadow: theme.tokens.elevation.md, @@ -185,30 +184,16 @@ export const FlavorCard = ({ justifyContent: 'space-between', gap: 1, mt: 'auto', + minHeight: 24, }} > - {hasRating ? ( - - ) : ( - - — - - )} + {hasRating && } {caption && ( {caption} diff --git a/frontend/src/components/ui/HeroGallery.tsx b/frontend/src/components/ui/HeroGallery.tsx index 08af4a0..4e2e6f2 100644 --- a/frontend/src/components/ui/HeroGallery.tsx +++ b/frontend/src/components/ui/HeroGallery.tsx @@ -1,11 +1,11 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Box, ButtonBase, IconButton, Modal, Typography, alpha, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import CloseIcon from '@mui/icons-material/Close'; import useEmblaCarousel from 'embla-carousel-react'; -import { GlassCard, GlassSurface } from './Glass'; +import { GlassCard } from './Glass'; export interface HeroGalleryProps { images: string[]; @@ -28,8 +28,6 @@ const Carousel: React.FC = ({ images, alt, onMainClick, variant } const [selectedIndex, setSelectedIndex] = useState(0); const [canPrev, setCanPrev] = useState(false); const [canNext, setCanNext] = useState(false); - const [counterVisible, setCounterVisible] = useState(true); - const counterTimeoutRef = useRef(null); const updateState = useCallback(() => { if (!emblaApi) return; @@ -38,23 +36,12 @@ const Carousel: React.FC = ({ images, alt, onMainClick, variant } setCanNext(emblaApi.canScrollNext()); }, [emblaApi]); - const flashCounter = useCallback(() => { - setCounterVisible(true); - if (counterTimeoutRef.current) window.clearTimeout(counterTimeoutRef.current); - counterTimeoutRef.current = window.setTimeout(() => setCounterVisible(false), 1500); - }, []); - useEffect(() => { if (!emblaApi) return; updateState(); emblaApi.on('select', updateState); emblaApi.on('reInit', updateState); - emblaApi.on('select', flashCounter); - flashCounter(); - return () => { - if (counterTimeoutRef.current) window.clearTimeout(counterTimeoutRef.current); - }; - }, [emblaApi, updateState, flashCounter]); + }, [emblaApi, updateState]); const scrollPrev = () => emblaApi?.scrollPrev(); const scrollNext = () => emblaApi?.scrollNext(); @@ -118,36 +105,6 @@ const Carousel: React.FC = ({ images, alt, onMainClick, variant } - {/* Counter pill */} - {images.length > 1 && ( - - - {selectedIndex + 1} / {images.length} - - - )} - {/* Chevrons (desktop only) */} {images.length > 1 && ( = ({ distribution, total, - height = 56, + height = 48, showLegend = true, }) => { const theme = useTheme(); @@ -29,24 +29,25 @@ const RatingDistribution: React.FC = ({ const interpolate = (n: number) => { const ratio = (n - 1) / 9; - return alpha(highColor, 0.25 + ratio * 0.7); + return alpha(highColor, 0.3 + ratio * 0.65); }; if (sum === 0) return null; return ( - + {showLegend && ( {t('flavorDetail.ratingDistribution')} @@ -58,30 +59,26 @@ const RatingDistribution: React.FC = ({ sx={{ display: 'flex', alignItems: 'flex-end', - gap: 0.5, + gap: 0.4, height, - px: 0.5, - py: 0.5, - borderRadius: theme.tokens.radius.sm + 'px', - bgcolor: alpha(theme.palette.text.primary, 0.04), - border: '1px solid', - borderColor: 'divider', + width: '100%', }} > {counts.map((c, i) => { const score = SCORES[i]; const ratio = c / max; - const minH = c > 0 ? 4 : 2; return ( = ({ 0 ? `calc(${ratio * 100}% + ${minH}px)` : `${minH}px`, - minHeight: minH, + height: c > 0 ? `max(${ratio * 100}%, 6px)` : '3px', bgcolor: c > 0 ? interpolate(score) : lowColor, - borderRadius: '3px', + borderRadius: '2px', transition: 'height 240ms ease, background-color 200ms ease', }} /> @@ -103,21 +99,6 @@ const RatingDistribution: React.FC = ({ ); })} - - - 1 - - - 10 - - ); }; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 4597e25..6572773 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -36,7 +36,7 @@ const resources = { exploreFlavors: 'Explore New Flavors', allRated: "Amazing! You've rated everything! 🏆", myReviews: 'My Rated Flavors', - exploreNew: 'Explore New', + exploreNew: 'To Try', editingReview: 'Editing review for {{flavor}}', newScore: 'New Score:', searchMissing: "Search flavors you haven't rated...", @@ -134,6 +134,7 @@ const resources = { delete: 'Delete', save: 'Save', cancel: 'Cancel', + all: 'All', }, status: { limited: 'Limited', @@ -338,7 +339,7 @@ const resources = { exploreFlavors: 'Neue Sorten entdecken', allRated: 'Wahnsinn! Du hast alles bewertet! 🏆', myReviews: 'Meine Sammlungen', - exploreNew: 'Neu entdecken', + exploreNew: 'Zum Probieren', editingReview: 'Rezension bearbeiten für {{flavor}}', newScore: 'Neue Bewertung:', searchMissing: 'Suche nach Sorten, die du noch nicht bewertet hast...', @@ -436,6 +437,7 @@ const resources = { delete: 'Löschen', save: 'Speichern', cancel: 'Abbrechen', + all: 'Alle', }, status: { limited: 'Limitiert', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9fbe1ad..1ad2d19 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,7 +4,8 @@ import { CardContent, Box, Button, - CircularProgress, + IconButton, + Skeleton, Avatar, Tabs, Tab, @@ -30,7 +31,7 @@ import CommentIcon from '@mui/icons-material/Comment'; import VerifiedIcon from '@mui/icons-material/Verified'; import SearchIcon from '@mui/icons-material/Search'; import ColorThief from 'colorthief'; -import { useNavigate, Link } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDashboard } from '../api/queries/useDashboard'; import { @@ -54,13 +55,26 @@ const Dashboard: React.FC = () => { const navigate = useNavigate(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('lg')); + const isXs = useMediaQuery(theme.breakpoints.down('sm')); useTitle(t('dashboard.title')); const { data, isLoading: loading } = useDashboard(); const updateRating = useUpdateRating(); const deleteRating = useDeleteRating(); const createReply = useCreateReply(); const deleteReply = useDeleteReply(); - const [activeTab, setActiveTab] = useState(0); + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get('tab') === 'explore' ? 1 : 0; + const setActiveTab = (v: number) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (v === 1) next.set('tab', 'explore'); + else next.delete('tab'); + return next; + }, + { replace: true }, + ); + }; const [palette, setPalette] = useState([]); const [exploreCategory, setExploreCategory] = useState('All'); @@ -93,6 +107,10 @@ const Dashboard: React.FC = () => { }; useEffect(() => { + if (isXs) { + setPalette([]); + return; + } if (data?.user?.avatar) { const img = new Image(); img.crossOrigin = 'Anonymous'; @@ -125,7 +143,7 @@ const Dashboard: React.FC = () => { } else { setPalette([]); } - }, [data?.user?.avatar]); + }, [data?.user?.avatar, isXs]); const handleUpdateRating = async (ratingId: number) => { try { @@ -221,9 +239,14 @@ const Dashboard: React.FC = () => { if (loading) return ( - - - + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + ); if (!data) @@ -238,51 +261,83 @@ const Dashboard: React.FC = () => { {/* Action Bar */} - + {isXs ? ( + + + + ) : ( + + )} {t('dashboard.title')} - + {isXs ? ( + + + + + + ) : ( + + )} {/* Profile Header — keeps inline ColorThief gradient per Phase-2 risk fallback */} - - - + {!isXs && ( + + + + )} - + { { - - + + {data.user.username} - - {t('dashboard.welcome', { username: data.user.username })} - + {!isXs && ( + + {t('dashboard.welcome', { username: data.user.username })} + + )} - + { { label: t('dashboard.missing'), val: data.missing_count }, ].map((stat, i) => ( - + { {/* TAB 0: MY REVIEWS */} {activeTab === 0 && ( - - - + + + { /> - + + + + + { key={cat} label={ cat === 'All' - ? t('nav.home').replace('Home', 'All') + ? t('common.all') : t( `categories.${data.my_ratings.find((r) => r.category_name === cat)?.category_slug}`, { defaultValue: cat }, @@ -441,23 +544,6 @@ const Dashboard: React.FC = () => { ))} - - - - @@ -520,16 +606,18 @@ const Dashboard: React.FC = () => { - + { sx={{ width: '100%', height: '100%', objectFit: 'cover' }} /> - - + + { direction="row" spacing={1} alignItems="center" - sx={{ mt: 0.5 }} + flexWrap="wrap" + sx={{ mt: 0.5, rowGap: 0.5 }} > {t(`categories.${rating.category_slug}`, { @@ -574,7 +670,12 @@ const Dashboard: React.FC = () => { - + + + { ))} - - - setReplyInputs({ ...replyInputs, [rating.id]: val }) - } - onKeyDown={(e) => - e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id) - } - /> + + + + setReplyInputs({ ...replyInputs, [rating.id]: val }) + } + onKeyDown={(e) => + e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id) + } + /> + @@ -728,9 +839,9 @@ const Dashboard: React.FC = () => { {/* TAB 1: EXPLORE NEW */} {activeTab === 1 && ( - - - + + + { /> - + + + + + { key={cat} label={ cat === 'All' - ? t('nav.home').replace('Home', 'All') + ? t('common.all') : t( `categories.${data.missing_flavors.find((f) => f.category_name === cat)?.category_slug}`, { defaultValue: cat }, @@ -778,23 +911,6 @@ const Dashboard: React.FC = () => { ))} - - - - diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index dd9460d..269344e 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -92,7 +92,9 @@ const MainPage: React.FC = (_props) => { const { data: topFlavorsAll = [], isLoading: topLoading } = useTopFlavors(null); const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors(); - const topFlavors = topFlavorsAll.slice(0, TOP_LIMIT); + 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;