desktop redesign

This commit is contained in:
nvrl committed 2026-06-16 18:38:53 +02:00
1 parent 12b973caac
commit 30a0ccda1b
6 files changed
+505 -28

No files matched your search

+7 -28
View File
@@ -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 = () => {
</GlassAppBar>
)}
{!isMobile && <DesktopTopBar />}
{isMobile && (
<SwipeableDrawer
anchor={mobileAnchor}
@@ -167,33 +170,9 @@ const App: React.FC = () => {
</SwipeableDrawer>
)}
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
{!isMobile && (
<Box
component="aside"
sx={{
width: NAV_SIDEBAR_WIDTH,
flexShrink: 0,
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
height: '100vh',
borderRight: '1px solid',
borderColor: (theme) => 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,
}}
>
<NavSidebar categories={categories} showSearch showNotifications />
</Box>
)}
{!isMobile && <DesktopCategoryBar categories={categories} />}
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
<Box
component="main"
sx={{
@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom';
import { Box, Typography } from '@mui/material';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import EmojiEventsOutlinedIcon from '@mui/icons-material/EmojiEventsOutlined';
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import { useTranslation } from 'react-i18next';
import { GlassCard, SectionHeader } from '../ui';
export interface CategoryBrowseGridProps {
categories: { name: string; slug: string }[];
}
/**
* Desktop-only category browser for the home page. Mirrors the new top-bar
* category emphasis with a tappable tile grid, so categories are reachable
* from the content area, not just the nav.
*/
export const CategoryBrowseGrid = ({ categories }: CategoryBrowseGridProps) => {
const { t } = useTranslation();
if (categories.length === 0) return null;
return (
<Box>
<SectionHeader
title={t('home.browseCategories', { defaultValue: 'Browse Categories' })}
subtitle={t('home.browseCategoriesSubtitle', {
defaultValue: 'Jump straight into a flavor line',
})}
/>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 2,
}}
>
{categories.map((cat) => (
<GlassCard
key={cat.slug}
intensity="subtle"
sx={{
overflow: 'hidden',
transition: 'transform 0.18s ease, box-shadow 0.18s ease',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: (theme) => theme.tokens.elevation.md,
},
'&::after': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
background: (theme) => theme.tokens.accent.gradient,
},
}}
>
<Box
component={Link}
to={`/category/${cat.slug}`}
sx={{
p: 2.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
textDecoration: 'none',
color: 'inherit',
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, pl: 1 }}>
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
</Typography>
<ArrowForwardIcon sx={{ fontSize: '1.1rem', color: 'text.secondary' }} />
</Box>
</GlassCard>
))}
</Box>
</Box>
);
};
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: <EmojiEventsOutlinedIcon />,
label: t('home.statTopRated', { defaultValue: 'Top Rated' }),
value: topName,
sub: topScore != null ? `${topScore.toFixed(1)} / 10` : undefined,
},
{
icon: <FiberNewOutlinedIcon />,
label: t('home.statNewest', { defaultValue: 'Newest Arrival' }),
value: newestName,
},
{
icon: <CategoryOutlinedIcon />,
label: t('home.statCategories', { defaultValue: 'Categories' }),
value: String(categoryCount),
},
].filter((i) => i.value);
if (items.length === 0) return null;
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(${items.length}, 1fr)`,
gap: 2,
}}
>
{items.map((item) => (
<GlassCard
key={item.label}
intensity="subtle"
sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 2 }}
>
<Box
sx={{
width: 44,
height: 44,
flexShrink: 0,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'primary.main',
bgcolor: 'action.hover',
}}
>
{item.icon}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
{item.label}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }} noWrap>
{item.value}
</Typography>
{item.sub && (
<Typography variant="caption" color="text.secondary">
{item.sub}
</Typography>
)}
</Box>
</GlassCard>
))}
</Box>
);
};
@@ -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 (
<Box
component="nav"
sx={{
position: 'sticky',
top: 64,
zIndex: (theme) => 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,
}}
>
<Box
sx={{
maxWidth: 1400,
mx: 'auto',
px: { md: 6 },
display: 'flex',
alignItems: 'center',
gap: 1,
minHeight: 52,
overflowX: 'auto',
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
<Button component={Link} to="/" disableRipple sx={tabSx(isActive('/'))}>
<HomeOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
{t('nav.home')}
</Button>
{user && (
<Button component={Link} to="/community" disableRipple sx={tabSx(isActive('/community'))}>
<GroupsOutlinedIcon sx={{ fontSize: '1.1rem', mr: 0.75 }} />
{t('nav.community')}
</Button>
)}
<Box
sx={{
alignSelf: 'stretch',
my: 1.25,
mx: 0.5,
borderLeft: '1px solid',
borderColor: 'divider',
}}
/>
{categories.map((cat) => {
const path = `/category/${cat.slug}`;
return (
<Button
key={cat.slug}
component={Link}
to={path}
disableRipple
sx={tabSx(location.pathname === path)}
>
{t(`categories.${cat.slug}`, { defaultValue: cat.name })}
</Button>
);
})}
</Box>
</Box>
);
};
@@ -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 | HTMLElement>(null);
const [acctAnchorEl, setAcctAnchorEl] = useState<null | HTMLElement>(null);
const acctOpen = Boolean(acctAnchorEl);
const closeAcct = () => setAcctAnchorEl(null);
const unreadCount = user?.unread_notifications_count || 0;
return (
<GlassAppBar>
<BrandMark />
<GlobalSearch />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{user ? (
<>
<IconButton color="inherit" onClick={(e) => setNotifAnchorEl(e.currentTarget)}>
<Badge badgeContent={unreadCount} color="error">
<NotificationsIcon />
</Badge>
</IconButton>
<NotificationMenu
anchorEl={notifAnchorEl}
open={Boolean(notifAnchorEl)}
onClose={() => setNotifAnchorEl(null)}
menuSx={{ mt: 1 }}
/>
<IconButton
onClick={(e) => setAcctAnchorEl(e.currentTarget)}
aria-label={t('nav.profile')}
aria-haspopup="true"
aria-expanded={acctOpen}
sx={{ ml: 0.5 }}
>
<Avatar
src={user.avatar || undefined}
sx={{ width: 34, height: 34, border: '2px solid', borderColor: 'primary.main' }}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</IconButton>
<Menu
anchorEl={acctAnchorEl}
open={acctOpen}
onClose={closeAcct}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { mt: 1, minWidth: 240 } } }}
>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }} noWrap>
{user.username}
</Typography>
</Box>
<Divider />
<MenuItem component={Link} to={`/profile/${user.username}`} onClick={closeAcct}>
<ListItemIcon>
<PersonOutlineOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('nav.profile')}</ListItemText>
</MenuItem>
<MenuItem component={Link} to="/dashboard" onClick={closeAcct}>
<ListItemIcon>
<CollectionsBookmarkOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('nav.dashboard')}</ListItemText>
</MenuItem>
<MenuItem component={Link} to="/settings" onClick={closeAcct}>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('nav.settings')}</ListItemText>
</MenuItem>
<MenuItem component={Link} to="/support" onClick={closeAcct}>
<ListItemIcon>
<HelpOutlineOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t('support.title')}</ListItemText>
</MenuItem>
{user.is_superuser && (
<MenuItem component={Link} to="/admin-panel" onClick={closeAcct}>
<ListItemIcon>
<AdminPanelSettingsOutlinedIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primaryTypographyProps={{ sx: { color: 'primary.main', fontWeight: 700 } }}
>
{t('admin.panelTitle', { defaultValue: 'Admin Panel' })}
</ListItemText>
</MenuItem>
)}
{following.length > 0 && (
<>
<Divider />
<Typography
variant="overline"
sx={{ px: 2, color: 'text.secondary', display: 'block' }}
>
{t('nav.following')} ({following.length})
</Typography>
<Box sx={{ maxHeight: 220, overflowY: 'auto' }}>
{following.map((f) => (
<MenuItem
key={f.id}
component={Link}
to={`/profile/${f.username}`}
onClick={closeAcct}
>
<ListItemIcon>
<Avatar src={f.avatar || undefined} sx={{ width: 24, height: 24 }}>
{!f.avatar && f.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemIcon>
<ListItemText primaryTypographyProps={{ variant: 'body2' }}>
{f.username}
</ListItemText>
</MenuItem>
))}
</Box>
</>
)}
<Divider />
<MenuItem
onClick={() => {
closeAcct();
logout();
}}
>
<ListItemIcon sx={{ color: 'error.main' }}>
<LogoutOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primaryTypographyProps={{ sx: { color: 'error.main', fontWeight: 600 } }}
>
{t('nav.logout')}
</ListItemText>
</MenuItem>
</Menu>
</>
) : (
<Button variant="contained" component={Link} to="/login" sx={{ borderRadius: 2 }}>
{t('nav.login')}
</Button>
)}
</Box>
</GlassAppBar>
);
};
+10
View File
@@ -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',
+19
View File
@@ -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<number | null>(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 = () => {
</GlassCard>
)}
{/* Desktop-only featured strip — fills the wider canvas */}
{isDesktop && (
<QuickStatsStrip
topName={currentTop?.name}
topScore={currentTop?.average_rating ?? undefined}
newestName={newestFlavors[0]?.name}
categoryCount={categories.length}
/>
)}
{/* Hall of Fame */}
<Box>
<SectionHeader
@@ -527,6 +543,9 @@ const MainPage: React.FC = () => {
)}
</Box>
{/* Desktop-only category browser */}
{isDesktop && <CategoryBrowseGrid categories={categories} />}
{/* Algorithmic discovery: personalized (members) or popular picks (visitors) */}
<MainPageRecommendations isLoggedIn={isLoggedIn} />