From 30a0ccda1ba9871e1786c664044a9b45cd45d7b8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 16 Jun 2026 18:38:53 +0200 Subject: [PATCH] desktop redesign --- frontend/src/App.tsx | 35 +--- .../components/home/DesktopHomeSections.tsx | 168 +++++++++++++++ .../components/layout/DesktopCategoryBar.tsx | 108 ++++++++++ .../src/components/layout/DesktopTopBar.tsx | 193 ++++++++++++++++++ frontend/src/i18n.ts | 10 + frontend/src/pages/MainPage.tsx | 19 ++ 6 files changed, 505 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/home/DesktopHomeSections.tsx create mode 100644 frontend/src/components/layout/DesktopCategoryBar.tsx create mode 100644 frontend/src/components/layout/DesktopTopBar.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe713ac..5dfc6fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { SwipeableDrawer, CircularProgress, Badge, - alpha, useMediaQuery, useTheme as useMuiTheme, } from '@mui/material'; @@ -22,8 +21,10 @@ import { GlobalSearch } from './app/GlobalSearch'; import { useAuth } from './hooks/useAuth'; import { useTheme } from './hooks/useTheme'; import { useDrawerAnchor } from './hooks/useDrawerAnchor'; -import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar'; +import { NavSidebar } from './components/layout/NavSidebar'; import { BrandMark } from './components/layout/BrandMark'; +import { DesktopTopBar } from './components/layout/DesktopTopBar'; +import { DesktopCategoryBar } from './components/layout/DesktopCategoryBar'; import { useCategories } from './api/queries/useCategories'; import { NotificationMenu } from './components/layout/NotificationMenu'; import { RequireSuperuser } from './components/auth/RequireSuperuser'; @@ -144,6 +145,8 @@ const App: React.FC = () => { )} + {!isMobile && } + {isMobile && ( { )} - - {!isMobile && ( - theme.tokens.glass.border, - bgcolor: (theme) => - alpha( - theme.palette.background.default, - theme.palette.mode === 'light' ? 0.65 : 0.45, - ), - backdropFilter: (theme) => theme.tokens.glass.blurStrong, - WebkitBackdropFilter: (theme) => theme.tokens.glass.blurStrong, - zIndex: 1, - }} - > - - - )} + {!isMobile && } + { + const { t } = useTranslation(); + if (categories.length === 0) return null; + + return ( + + + + {categories.map((cat) => ( + theme.tokens.elevation.md, + }, + '&::after': { + content: '""', + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + background: (theme) => theme.tokens.accent.gradient, + }, + }} + > + + + {t(`categories.${cat.slug}`, { defaultValue: cat.name })} + + + + + ))} + + + ); +}; + +export interface QuickStatsStripProps { + topName?: string; + topScore?: number; + newestName?: string; + categoryCount: number; +} + +/** Desktop-only featured strip — fills the wider canvas with at-a-glance picks. */ +export const QuickStatsStrip = ({ + topName, + topScore, + newestName, + categoryCount, +}: QuickStatsStripProps) => { + const { t } = useTranslation(); + + const items = [ + { + icon: , + label: t('home.statTopRated', { defaultValue: 'Top Rated' }), + value: topName, + sub: topScore != null ? `${topScore.toFixed(1)} / 10` : undefined, + }, + { + icon: , + label: t('home.statNewest', { defaultValue: 'Newest Arrival' }), + value: newestName, + }, + { + icon: , + label: t('home.statCategories', { defaultValue: 'Categories' }), + value: String(categoryCount), + }, + ].filter((i) => i.value); + + if (items.length === 0) return null; + + return ( + + {items.map((item) => ( + + + {item.icon} + + + + {item.label} + + + {item.value} + + {item.sub && ( + + {item.sub} + + )} + + + ))} + + ); +}; diff --git a/frontend/src/components/layout/DesktopCategoryBar.tsx b/frontend/src/components/layout/DesktopCategoryBar.tsx new file mode 100644 index 0000000..c5b955d --- /dev/null +++ b/frontend/src/components/layout/DesktopCategoryBar.tsx @@ -0,0 +1,108 @@ +import { Link, useLocation } from 'react-router-dom'; +import { Box, Button, alpha } from '@mui/material'; +import type { Theme } from '@mui/material'; +import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; +import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../../hooks/useAuth'; + +export interface DesktopCategoryBarProps { + categories: { name: string; slug: string }[]; +} + +/** + * Desktop horizontal nav (md+), pinned just under the top bar. Categories are + * the dominant browse axis here — laid out as a scrollable row of tabs — with + * Home / Community as primary links on the left. Replaces the old left rail. + */ +export const DesktopCategoryBar = ({ categories }: DesktopCategoryBarProps) => { + const { t } = useTranslation(); + const location = useLocation(); + const { user } = useAuth(); + + const isActive = (path: string) => + path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); + + const tabSx = (active: boolean) => ({ + flexShrink: 0, + borderRadius: 2, + px: 1.75, + py: 0.75, + fontWeight: active ? 700 : 500, + color: active ? 'primary.main' : 'text.secondary', + bgcolor: active ? (theme: Theme) => alpha(theme.palette.primary.main, 0.12) : 'transparent', + '&:hover': { + color: 'text.primary', + bgcolor: 'action.hover', + }, + }); + + return ( + theme.zIndex.appBar - 1, + borderBottom: '1px solid', + borderColor: (theme) => theme.tokens.glass.border, + backgroundColor: (theme) => + alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.8 : 0.6), + backdropFilter: (theme) => theme.tokens.glass.blurStrong, + WebkitBackdropFilter: (theme) => theme.tokens.glass.blurStrong, + }} + > + + + + {user && ( + + )} + + + + {categories.map((cat) => { + const path = `/category/${cat.slug}`; + return ( + + ); + })} + + + ); +}; diff --git a/frontend/src/components/layout/DesktopTopBar.tsx b/frontend/src/components/layout/DesktopTopBar.tsx new file mode 100644 index 0000000..959c9f8 --- /dev/null +++ b/frontend/src/components/layout/DesktopTopBar.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Avatar, + Badge, + Box, + Button, + Divider, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Typography, +} from '@mui/material'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +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 LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined'; +import { useTranslation } from 'react-i18next'; +import { GlassAppBar } from '../ui'; +import { GlobalSearch } from '../../app/GlobalSearch'; +import { useAuth } from '../../hooks/useAuth'; +import { BrandMark } from './BrandMark'; +import { NotificationMenu } from './NotificationMenu'; + +/** + * Desktop top bar (md+). Promotes global search to the primary, centered slot; + * notifications + an account menu sit on the right. Everything that used to be + * a flat sidebar row (profile, dashboard, settings, support, admin, following, + * logout) now lives behind the avatar menu so the chrome stays light. + */ +export const DesktopTopBar = () => { + const { t } = useTranslation(); + const { user, following, logout } = useAuth(); + + const [notifAnchorEl, setNotifAnchorEl] = useState(null); + const [acctAnchorEl, setAcctAnchorEl] = useState(null); + const acctOpen = Boolean(acctAnchorEl); + const closeAcct = () => setAcctAnchorEl(null); + + const unreadCount = user?.unread_notifications_count || 0; + + return ( + + + + + + + {user ? ( + <> + setNotifAnchorEl(e.currentTarget)}> + + + + + setNotifAnchorEl(null)} + menuSx={{ mt: 1 }} + /> + + setAcctAnchorEl(e.currentTarget)} + aria-label={t('nav.profile')} + aria-haspopup="true" + aria-expanded={acctOpen} + sx={{ ml: 0.5 }} + > + + {user.username.charAt(0).toUpperCase()} + + + + + + + {user.username} + + + + + + + + + {t('nav.profile')} + + + + + + {t('nav.dashboard')} + + + + + + {t('nav.settings')} + + + + + + {t('support.title')} + + + {user.is_superuser && ( + + + + + + {t('admin.panelTitle', { defaultValue: 'Admin Panel' })} + + + )} + + {following.length > 0 && ( + <> + + + {t('nav.following')} ({following.length}) + + + {following.map((f) => ( + + + + {!f.avatar && f.username.charAt(0).toUpperCase()} + + + + {f.username} + + + ))} + + + )} + + + { + closeAcct(); + logout(); + }} + > + + + + + {t('nav.logout')} + + + + + ) : ( + + )} + + + ); +}; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 056131a..ce3c280 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -114,6 +114,11 @@ const resources = { exploreFlavors: 'Explore Flavors', hallOfFame: 'Hall of Fame', hallOfFameSubtitle: 'Highest-rated flavors in the archive', + browseCategories: 'Browse Categories', + browseCategoriesSubtitle: 'Jump straight into a flavor line', + statTopRated: 'Top Rated', + statNewest: 'Newest Arrival', + statCategories: 'Categories', recForYou: 'Recommended for You', recForYouSubtitle: 'Picked by tasters who share your palate', popularPicks: 'Popular Picks', @@ -645,6 +650,11 @@ const resources = { exploreFlavors: 'Sorten entdecken', hallOfFame: 'Hall of Fame', hallOfFameSubtitle: 'Die bestbewerteten Sorten im Archiv', + browseCategories: 'Kategorien durchstöbern', + browseCategoriesSubtitle: 'Direkt zu einer Sorten-Linie springen', + statTopRated: 'Bestbewertet', + statNewest: 'Neuheit', + statCategories: 'Kategorien', recForYou: 'Für dich empfohlen', recForYouSubtitle: 'Ausgewählt von Testern mit ähnlichem Geschmack', popularPicks: 'Beliebte Sorten', diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index b44c55e..64ad0f3 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -9,6 +9,7 @@ import { IconButton, Skeleton, useMediaQuery, + useTheme, } from '@mui/material'; import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -34,6 +35,8 @@ import { EmptyState, } from '../components/ui'; import MainPageRecommendations from './MainPageRecommendations'; +import { CategoryBrowseGrid, QuickStatsStrip } from '../components/home/DesktopHomeSections'; +import { useCategories } from '../api/queries/useCategories'; const TOP_LIMIT = 5; const AUTO_ADVANCE_MS = 6000; @@ -91,6 +94,8 @@ const MainPage: React.FC = () => { const [autoPlay, setAutoPlay] = useState(true); const [hovered, setHovered] = useState(false); const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const muiTheme = useTheme(); + const isDesktop = useMediaQuery(muiTheme.breakpoints.up('md')); const touchStartX = useRef(null); const query = new URLSearchParams(location.search).get('q') || ''; @@ -100,6 +105,7 @@ const MainPage: React.FC = () => { const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query); const { data: topFlavorsAll = [], isLoading: topLoading } = useTopFlavors(null); const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors(); + const { data: categories = [] } = useCategories(); const topFlavors = topFlavorsAll .filter((f) => f.category_slug !== 'packs-and-other') @@ -268,6 +274,16 @@ const MainPage: React.FC = () => { )} + {/* Desktop-only featured strip — fills the wider canvas */} + {isDesktop && ( + + )} + {/* Hall of Fame */} { )} + {/* Desktop-only category browser */} + {isDesktop && } + {/* Algorithmic discovery: personalized (members) or popular picks (visitors) */}