desktop side/top bar redesign + mobile top bar redesign

This commit is contained in:
nvrl committed 2026-05-08 21:37:38 +02:00
1 parent 683354ec93
commit fdb067cf1e
3 files changed
+181 -94

No files matched your search

+33 -84
View File
@@ -18,7 +18,6 @@ import {
import { GlassAppBar } from './components/ui'; import { GlassAppBar } from './components/ui';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsIcon from '@mui/icons-material/Notifications';
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import api from './lib/api'; import api from './lib/api';
import Footer from './components/Footer'; import Footer from './components/Footer';
@@ -62,16 +61,15 @@ const App: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const muiTheme = useMuiTheme(); const muiTheme = useMuiTheme();
const { user, loadingUser, logout } = useAuth(); const { user, loadingUser } = useAuth();
const { themeName, handleThemeChange } = useTheme(); const { themeName, handleThemeChange } = useTheme();
const { notifications, markAllRead, markRead } = useNotifications(); const { notifications, markAllRead, markRead } = useNotifications();
const { anchor: mobileAnchor } = useDrawerAnchor(); const { anchor: mobileAnchor } = useDrawerAnchor();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null); const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]); const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]);
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md')); const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
const [drawerOpen, setDrawerOpen] = useState(!isMobile); const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => { useEffect(() => {
api api
@@ -90,7 +88,6 @@ const App: React.FC = () => {
const onThemeChange = async (name: CatppuccinTheme) => { const onThemeChange = async (name: CatppuccinTheme) => {
await handleThemeChange(name, !!user); await handleThemeChange(name, !!user);
setAnchorEl(null);
}; };
const handleNotificationClick = async (notif: Notification) => { const handleNotificationClick = async (notif: Notification) => {
@@ -216,85 +213,34 @@ const App: React.FC = () => {
overflowX: 'hidden', overflowX: 'hidden',
}} }}
> >
<GlassAppBar> {isMobile && (
{mobileAnchor === 'left' && hamburger} <GlassAppBar>
{mobileAnchor === 'left' && hamburger}
<Typography <GlobalSearch />
variant="h6"
component="div"
sx={{
fontWeight: 'bold',
mr: { xs: 0, sm: 2 },
}}
>
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
Holy Flavors Archive
</Box>
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
HFA
</Box>
</Link>
</Typography>
<GlobalSearch /> <Box sx={{ display: 'flex', alignItems: 'center' }}>
{!loadingUser && (
<>
{user && notificationsMenu}
<Box sx={{ display: 'flex', alignItems: 'center' }}> {!user && (
{!loadingUser && ( <Button
<> variant="contained"
{user && notificationsMenu} component={Link}
to="/login"
{user ? ( sx={{ borderRadius: 2, ml: 1 }}
<Box sx={{ display: isMobile ? 'none' : 'flex', ml: 1 }}>
<IconButton
color="inherit"
onClick={(e) => setAnchorEl(e.currentTarget)}
aria-label="account menu"
> >
<Avatar {t('nav.login')}
src={user.avatar || undefined}
sx={{
width: 36,
height: 36,
border: '2px solid',
borderColor: 'primary.main',
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
elevation={3}
sx={{ mt: 1 }}
>
<MenuItem
onClick={() => {
setAnchorEl(null);
logout();
}}
sx={{ color: 'error.main', gap: 1.5 }}
>
<LogoutOutlinedIcon fontSize="small" />
{t('nav.logout')}
</MenuItem>
</Menu>
</Box>
) : (
<Box sx={{ display: isMobile ? 'none' : 'block' }}>
<Button variant="contained" component={Link} to="/login" sx={{ borderRadius: 2 }}>
Login
</Button> </Button>
</Box> )}
)} </>
</> )}
)}
{mobileAnchor === 'right' && hamburger} {mobileAnchor === 'right' && hamburger}
</Box> </Box>
</GlassAppBar> </GlassAppBar>
)}
{isMobile && ( {isMobile && (
<SwipeableDrawer <SwipeableDrawer
@@ -310,23 +256,26 @@ const App: React.FC = () => {
)} )}
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}> <Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
{!isMobile && drawerOpen && ( {!isMobile && (
<Box <Box
component="aside" component="aside"
sx={{ sx={{
width: NAV_SIDEBAR_WIDTH, width: NAV_SIDEBAR_WIDTH,
flexShrink: 0, flexShrink: 0,
position: 'sticky', position: 'sticky',
top: { xs: 56, sm: 64 }, top: 0,
alignSelf: 'flex-start', alignSelf: 'flex-start',
height: { xs: 'calc(100vh - 56px)', sm: 'calc(100vh - 64px)' }, height: '100vh',
borderRight: '1px solid', borderRight: '1px solid',
borderColor: 'divider', borderColor: (theme) => theme.tokens.glass.border,
bgcolor: 'background.paper', 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, zIndex: 1,
}} }}
> >
<NavSidebar categories={categories} /> <NavSidebar categories={categories} showSearch showNotifications />
</Box> </Box>
)} )}
+6 -2
View File
@@ -14,7 +14,11 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useGlobalSearch } from '../hooks/useGlobalSearch'; import { useGlobalSearch } from '../hooks/useGlobalSearch';
export const GlobalSearch = () => { export interface GlobalSearchProps {
compact?: boolean;
}
export const GlobalSearch = ({ compact = false }: GlobalSearchProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
@@ -31,7 +35,7 @@ export const GlobalSearch = () => {
}; };
return ( return (
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}> <Box sx={{ flexGrow: 1, mx: compact ? 0 : isMobile ? 1 : 4 }}>
<Autocomplete <Autocomplete
fullWidth fullWidth
freeSolo freeSolo
+142 -8
View File
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { import {
Avatar, Avatar,
Badge,
Box, Box,
Button,
Collapse, Collapse,
Divider, Divider,
List, List,
@@ -11,7 +13,11 @@ import {
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Menu,
MenuItem,
Typography, Typography,
alpha,
useTheme,
} from '@mui/material'; } from '@mui/material';
import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore'; import ExpandMore from '@mui/icons-material/ExpandMore';
@@ -25,28 +31,61 @@ import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined';
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined'; import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined'; import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined';
import NotificationsOutlinedIcon from '@mui/icons-material/NotificationsOutlined';
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'; import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { useNotifications } from '../../hooks/useNotifications';
import { GlobalSearch } from '../../app/GlobalSearch';
import { formatDate } from '../../utils/date';
import type { Notification } from '../../contexts/NotificationContext';
export const NAV_SIDEBAR_WIDTH = 280; export const NAV_SIDEBAR_WIDTH = 280;
export interface NavSidebarProps { export interface NavSidebarProps {
categories: { name: string; slug: string }[]; categories: { name: string; slug: string }[];
onNavigate?: () => void; onNavigate?: () => void;
showSearch?: boolean;
showNotifications?: boolean;
} }
export const NavSidebar = ({ categories, onNavigate }: NavSidebarProps) => { export const NavSidebar = ({
categories,
onNavigate,
showSearch = false,
showNotifications = false,
}: NavSidebarProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const theme = useTheme();
const { user, following, logout } = useAuth(); const { user, following, logout } = useAuth();
const { notifications, markAllRead, markRead } = useNotifications();
const [catOpen, setCatOpen] = useState(false); const [catOpen, setCatOpen] = useState(false);
const [followingOpen, setFollowingOpen] = useState(false); const [followingOpen, setFollowingOpen] = useState(false);
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
const close = () => onNavigate?.(); const close = () => onNavigate?.();
const isActive = (path: string) => const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
const handleNotificationClick = async (notif: Notification) => {
if (!notif.is_read) await markRead(notif.id);
setNotifAnchorEl(null);
close();
if (notif.notification_type.startsWith('ticket')) {
navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support');
} else if (notif.notification_type === 'profile_comment') {
navigate(`/profile/${user?.username}`);
} else if (notif.notification_type === 'follow') {
navigate(`/profile/${notif.actor_username}`);
} else if (notif.flavor_id) {
window.location.href = `/flavor/${notif.flavor_id}`;
}
};
const unreadCount = user?.unread_notifications_count || 0;
return ( return (
<Box <Box
sx={{ sx={{
@@ -103,13 +142,11 @@ export const NavSidebar = ({ categories, onNavigate }: NavSidebarProps) => {
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
) : ( ) : null}
{showSearch && (
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}> <Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <GlobalSearch compact />
<Link to="/" onClick={close} style={{ color: 'inherit', textDecoration: 'none' }}>
Holy Flavors Archive
</Link>
</Typography>
</Box> </Box>
)} )}
@@ -139,6 +176,21 @@ export const NavSidebar = ({ categories, onNavigate }: NavSidebarProps) => {
</ListItem> </ListItem>
)} )}
{user && showNotifications && (
<ListItem disablePadding>
<ListItemButton onClick={(e) => setNotifAnchorEl(e.currentTarget)}>
<ListItemIcon sx={{ minWidth: 40 }}>
<Badge badgeContent={unreadCount} color="error">
<NotificationsOutlinedIcon />
</Badge>
</ListItemIcon>
<ListItemText
primary={t('nav.notifications', { defaultValue: 'Notifications' })}
/>
</ListItemButton>
</ListItem>
)}
<ListItemButton <ListItemButton
onClick={() => setCatOpen(!catOpen)} onClick={() => setCatOpen(!catOpen)}
selected={location.pathname.startsWith('/category/')} selected={location.pathname.startsWith('/category/')}
@@ -332,6 +384,88 @@ export const NavSidebar = ({ categories, onNavigate }: NavSidebarProps) => {
</ListItemButton> </ListItemButton>
</Box> </Box>
)} )}
{showNotifications && (
<Menu
anchorEl={notifAnchorEl}
open={Boolean(notifAnchorEl)}
onClose={() => setNotifAnchorEl(null)}
anchorOrigin={{ vertical: 'center', horizontal: 'right' }}
transformOrigin={{ vertical: 'center', horizontal: 'left' }}
elevation={3}
PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1, ml: 1 } }}
>
<Box
sx={{
p: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{t('nav.notifications', { defaultValue: 'Notifications' })}
</Typography>
{unreadCount > 0 && (
<Button size="small" onClick={markAllRead}>
{t('community.markAllRead', { defaultValue: 'Mark all read' })}
</Button>
)}
</Box>
{notifications.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('community.noNotifications', { defaultValue: 'No notifications yet' })}
</Typography>
</Box>
) : (
notifications.map((n) => (
<MenuItem
key={n.id}
onClick={() => handleNotificationClick(n)}
sx={{
py: 1.5,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05),
whiteSpace: 'normal',
display: 'flex',
gap: 2,
alignItems: 'flex-start',
}}
>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
{n.actor_username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
<strong>{n.actor_username}</strong>{' '}
{n.notification_type === 'reply'
? `replied to your review on ${n.flavor_name}`
: n.notification_type === 'mention'
? `mentioned you on ${n.flavor_name}`
: n.notification_type === 'follow'
? t('community.notifFollow')
: n.notification_type === 'profile_comment'
? `left a message on your guestbook`
: n.notification_type === 'ticket_new'
? t('community.notifTicketNew')
: user?.is_superuser
? t('community.notifTicketReplyAdmin')
: t('community.notifTicketReply')}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(n.created_at)}
</Typography>
</Box>
</MenuItem>
))
)}
</Menu>
)}
</Box> </Box>
); );
}; };