feat: Add mobile scroll to top button and gradient header background

This commit is contained in:
Peifan Li
2025-12-19 18:25:09 -05:00
parent 3d20eac71a
commit b637a66a4c
5 changed files with 262 additions and 77 deletions

View File

@@ -1,11 +1,14 @@
import { Download } from '@mui/icons-material';
import {
alpha,
Box,
CircularProgress,
Fade,
Menu,
MenuItem,
Typography
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -26,12 +29,15 @@ const DownloadsMenu: React.FC<DownloadsMenuProps> = ({
}) => {
const navigate = useNavigate();
const { t } = useLanguage();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
return (
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={onClose}
disableScrollLock
slotProps={{
paper: {
elevation: 0,
@@ -42,6 +48,8 @@ const DownloadsMenu: React.FC<DownloadsMenuProps> = ({
width: 320,
maxHeight: '50vh',
overflowY: 'auto',
bgcolor: !isMobile ? alpha(theme.palette.background.paper, 0.7) : 'background.paper',
backdropFilter: !isMobile ? 'blur(10px)' : 'none',
'& .MuiAvatar-root': {
width: 32,
height: 32,
@@ -56,7 +64,7 @@ const DownloadsMenu: React.FC<DownloadsMenuProps> = ({
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
bgcolor: !isMobile ? alpha(theme.palette.background.paper, 0.7) : 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},

View File

@@ -1,8 +1,11 @@
import { Help, Settings, Subscriptions, VideoLibrary } from '@mui/icons-material';
import {
alpha,
Fade,
Menu,
MenuItem
MenuItem,
useMediaQuery,
useTheme
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -18,12 +21,15 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
}) => {
const navigate = useNavigate();
const { t } = useLanguage();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
return (
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={onClose}
disableScrollLock
slotProps={{
paper: {
elevation: 0,
@@ -32,6 +38,8 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
width: 320,
bgcolor: !isMobile ? alpha(theme.palette.background.paper, 0.7) : 'background.paper',
backdropFilter: !isMobile ? 'blur(10px)' : 'none',
'&:before': {
content: '""',
display: 'block',
@@ -40,7 +48,7 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
bgcolor: !isMobile ? alpha(theme.palette.background.paper, 0.7) : 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},

View File

@@ -1,11 +1,14 @@
import { Clear, Search } from '@mui/icons-material';
import {
alpha,
Box,
Button,
CircularProgress,
IconButton,
InputAdornment,
TextField
TextField,
useMediaQuery,
useTheme
} from '@mui/material';
import { FormEvent } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -34,6 +37,8 @@ const SearchInput: React.FC<SearchInputProps> = ({
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
return (
<Box component="form" onSubmit={onSubmit} sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', width: '100%' }}>
@@ -47,6 +52,12 @@ const SearchInput: React.FC<SearchInputProps> = ({
error={!!error}
helperText={error}
size="small"
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: !isMobile ? alpha(theme.palette.background.paper, 0.1) : 'background.paper',
backdropFilter: !isMobile ? 'blur(10px)' : 'none',
}
}}
slotProps={{
input: {
endAdornment: (

View File

@@ -1,9 +1,12 @@
import { Menu as MenuIcon } from '@mui/icons-material';
import { Menu as MenuIcon, VerticalAlignTop } from '@mui/icons-material';
import {
alpha,
AppBar,
Box,
ClickAwayListener,
Fab,
IconButton,
Slide,
Toolbar,
useMediaQuery,
useTheme
@@ -11,6 +14,7 @@ import {
import { FormEvent, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useThemeContext } from '../../contexts/ThemeContext';
import { useVideo } from '../../contexts/VideoContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import ActionButtons from './ActionButtons';
@@ -36,8 +40,10 @@ const Header: React.FC<HeaderProps> = ({
const [manageAnchorEl, setManageAnchorEl] = useState<null | HTMLElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
const [websiteName, setWebsiteName] = useState('MyTube');
const [isScrolled, setIsScrolled] = useState<boolean>(false);
const navigate = useNavigate();
const theme = useTheme();
const { mode: themeMode } = useThemeContext();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
@@ -63,6 +69,27 @@ const Header: React.FC<HeaderProps> = ({
fetchSettings();
}, []);
// Scroll detection - only for mobile
useEffect(() => {
if (!isMobile) {
setIsScrolled(false);
return;
}
const handleScroll = () => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
setIsScrolled(scrollTop > 50); // Threshold of 50px
};
window.addEventListener('scroll', handleScroll, { passive: true });
// Check initial scroll position
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [isMobile]);
const handleDownloadsClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
@@ -120,12 +147,96 @@ const Header: React.FC<HeaderProps> = ({
}
};
// Get background color for gradient
const backgroundColor = themeMode === 'dark'
? theme.palette.background.default
: theme.palette.background.paper;
// Create gradient from top (0% transparency) to bottom (100% transparency)
const gradientBackground = `linear-gradient(to bottom, ${backgroundColor} 0%, ${alpha(backgroundColor, 0)} 100%)`;
// Desktop background: 30% transparent (70% opacity)
const desktopBackgroundColor = !isMobile
? alpha(theme.palette.background.paper, 0.7)
: 'background.paper';
return (
<>
<ClickAwayListener onClickAway={() => setMobileMenuOpen(false)}>
<AppBar position="sticky" color="default" elevation={0} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
<Toolbar sx={{ flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', py: isMobile ? 1 : 0 }}>
<AppBar
position="fixed"
color="default"
elevation={0}
sx={{
top: 0,
left: 0,
right: 0,
width: '100%',
maxWidth: '100%',
zIndex: (theme) => theme.zIndex.appBar,
bgcolor: (isMobile && isScrolled) ? 'transparent' : desktopBackgroundColor,
backgroundImage: (isMobile && isScrolled) ? gradientBackground : 'none',
borderBottom: (isMobile && isScrolled) ? 0 : 1,
borderColor: 'divider',
transition: 'background-color 0.3s ease-in-out, background-image 0.3s ease-in-out, border-bottom 0.3s ease-in-out, backdrop-filter 0.3s ease-in-out, height 0.3s ease-in-out',
backdropFilter: (isMobile && isScrolled) ? 'none' : 'blur(10px)',
boxSizing: 'border-box',
}}
>
<Toolbar
sx={{
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center',
py: isMobile ? (isScrolled ? 0.5 : 1) : 0,
minHeight: isMobile
? (isScrolled ? '40px !important' : undefined)
: undefined,
transition: 'min-height 0.3s ease-in-out, padding 0.3s ease-in-out',
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
}}
>
{(isMobile && isScrolled) ? (
// Simplified header when scrolled
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
py: 0,
px: 2,
transition: 'all 0.3s ease-in-out',
'& img': {
height: '24px !important',
transition: 'height 0.3s ease-in-out',
},
'& .MuiTypography-h5': {
fontSize: '1rem !important',
transition: 'font-size 0.3s ease-in-out',
},
}}>
<Logo websiteName={websiteName} onResetSearch={onResetSearch} />
</Box>
) : (
// Full header when at top
<>
{/* Top Bar for Mobile / Main Bar for Desktop */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: isMobile ? '100%' : 'auto', flexGrow: isMobile ? 0 : 0, mr: isMobile ? 0 : 2 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: isMobile ? '100%' : 'auto',
flexGrow: isMobile ? 0 : 0,
mr: isMobile ? 0 : 2,
transition: 'all 0.3s ease-in-out',
'& img': {
transition: 'height 0.3s ease-in-out',
},
'& .MuiTypography-h5': {
transition: 'font-size 0.3s ease-in-out',
},
}}>
<Logo websiteName={websiteName} onResetSearch={onResetSearch} />
{isMobile && (
@@ -199,9 +310,53 @@ const Header: React.FC<HeaderProps> = ({
onTagToggle={handleTagToggle}
/>
)}
</>
)}
</Toolbar>
</AppBar>
</ClickAwayListener>
{/* Spacer to prevent content from going under fixed header */}
<Box
sx={{
height: () => {
// Get the actual toolbar height - it varies based on mobile/desktop and scrolled state
if (isMobile && isScrolled) {
return '40px'; // Simplified header height on mobile (1/2 of normal)
}
// Mobile normal header: default Toolbar (64px) + padding (16px) + content ≈ 50px (user adjusted)
// Desktop: 64px
return isMobile ? '50px' : '64px';
},
flexShrink: 0,
transition: 'height 0.3s ease-in-out',
}}
/>
{/* Scroll to top button - mobile only */}
<Slide direction="up" in={isMobile && isScrolled} mountOnEnter unmountOnExit>
<Fab
color="primary"
size="small"
aria-label="scroll to top"
onClick={() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
sx={{
position: 'fixed',
bottom: 16,
left: 16,
zIndex: (theme) => theme.zIndex.speedDial,
display: { xs: 'flex', md: 'none' },
opacity: 0.8,
'&:hover': {
opacity: 1,
},
}}
>
<VerticalAlignTop />
</Fab>
</Slide>
</>
);
};

View File

@@ -11,7 +11,10 @@
"dev:frontend": "cd frontend && npm run dev",
"dev:backend": "cd backend && npm run dev",
"install:all": "npm install && cd frontend && npm install && cd ../backend && npm install",
"build": "cd frontend && npm run build"
"build": "cd frontend && npm run build",
"test": "concurrently \"npm run test:backend\" \"npm run test:frontend\"",
"test:frontend": "cd frontend && npm run test",
"test:backend": "cd backend && npm run test"
},
"keywords": [
"youtube",