feat: new mobile menu (#3251)
parent
54ea50eb46
commit
df638f9feb
@ -0,0 +1,206 @@
|
|||||||
|
import { menuMessages } from '@app/components/Layout/Sidebar';
|
||||||
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
CogIcon,
|
||||||
|
EllipsisHorizontalIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
FilmIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
TvIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import {
|
||||||
|
ClockIcon as FilledClockIcon,
|
||||||
|
CogIcon as FilledCogIcon,
|
||||||
|
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
|
||||||
|
FilmIcon as FilledFilmIcon,
|
||||||
|
SparklesIcon as FilledSparklesIcon,
|
||||||
|
TvIcon as FilledTvIcon,
|
||||||
|
UsersIcon as FilledUsersIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { cloneElement, useRef, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
interface MenuLink {
|
||||||
|
href: string;
|
||||||
|
svgIcon: JSX.Element;
|
||||||
|
svgIconSelected: JSX.Element;
|
||||||
|
content: React.ReactNode;
|
||||||
|
activeRegExp: RegExp;
|
||||||
|
as?: string;
|
||||||
|
requiredPermission?: Permission | Permission[];
|
||||||
|
permissionType?: 'and' | 'or';
|
||||||
|
dataTestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileMenu = () => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const intl = useIntl();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
useClickOutside(ref, () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
const menuLinks: MenuLink[] = [
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
content: intl.formatMessage(menuMessages.dashboard),
|
||||||
|
svgIcon: <SparklesIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledSparklesIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/(discover\/?)?$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/movies',
|
||||||
|
content: intl.formatMessage(menuMessages.browsemovies),
|
||||||
|
svgIcon: <FilmIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledFilmIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/movies$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/discover/tv',
|
||||||
|
content: intl.formatMessage(menuMessages.browsetv),
|
||||||
|
svgIcon: <TvIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/discover\/tv$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/requests',
|
||||||
|
content: intl.formatMessage(menuMessages.requests),
|
||||||
|
svgIcon: <ClockIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/requests/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/issues',
|
||||||
|
content: intl.formatMessage(menuMessages.issues),
|
||||||
|
svgIcon: <ExclamationTriangleIcon className="h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledExclamationTriangleIcon className="h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/issues/,
|
||||||
|
requiredPermission: [
|
||||||
|
Permission.MANAGE_ISSUES,
|
||||||
|
Permission.CREATE_ISSUES,
|
||||||
|
Permission.VIEW_ISSUES,
|
||||||
|
],
|
||||||
|
permissionType: 'or',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/users',
|
||||||
|
content: intl.formatMessage(menuMessages.users),
|
||||||
|
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledUsersIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/users/,
|
||||||
|
requiredPermission: Permission.MANAGE_USERS,
|
||||||
|
dataTestId: 'sidebar-menu-users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings',
|
||||||
|
content: intl.formatMessage(menuMessages.settings),
|
||||||
|
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
|
||||||
|
svgIconSelected: <FilledCogIcon className="mr-3 h-6 w-6" />,
|
||||||
|
activeRegExp: /^\/settings/,
|
||||||
|
requiredPermission: Permission.ADMIN,
|
||||||
|
dataTestId: 'sidebar-menu-settings',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredLinks = menuLinks.filter(
|
||||||
|
(link) => !link.requiredPermission || hasPermission(link.requiredPermission)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||||
|
<Transition
|
||||||
|
show={isOpen}
|
||||||
|
as="div"
|
||||||
|
ref={ref}
|
||||||
|
enter="transition transform duration-500"
|
||||||
|
enterFrom="opacity-0 translate-y-0"
|
||||||
|
enterTo="opacity-100 -translate-y-full"
|
||||||
|
leave="transition duration-500 transform"
|
||||||
|
leaveFrom="opacity-100 -translate-y-full"
|
||||||
|
leaveTo="opacity-0 translate-y-0"
|
||||||
|
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
||||||
|
>
|
||||||
|
{filteredLinks.map((link) => {
|
||||||
|
const isActive = router.pathname.match(link.activeRegExp);
|
||||||
|
return (
|
||||||
|
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||||
|
<a
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
isActive ? 'text-indigo-500' : ''
|
||||||
|
}`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
||||||
|
className: 'h-5 w-5',
|
||||||
|
})}
|
||||||
|
<span>{link.content}</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Transition>
|
||||||
|
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
|
||||||
|
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
|
||||||
|
{filteredLinks.slice(0, 4).map((link) => {
|
||||||
|
const isActive =
|
||||||
|
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||||
|
return (
|
||||||
|
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||||
|
<a
|
||||||
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
|
isActive ? 'text-indigo-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cloneElement(
|
||||||
|
isActive ? link.svgIconSelected : link.svgIcon,
|
||||||
|
{
|
||||||
|
className: 'h-6 w-6',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredLinks.length > 4 && (
|
||||||
|
<button
|
||||||
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
|
isOpen ? 'text-indigo-500' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => toggle()}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<EllipsisHorizontalIcon className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileMenu;
|
Loading…
Reference in new issue