mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: new mobile menu (#3251)
This commit is contained in:
@@ -153,12 +153,12 @@ const Discover = () => {
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="fixed bottom-8 right-8 z-50 flex items-center"
|
||||
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="discover-start-editing"
|
||||
className="h-12 w-12 rounded-full border border-gray-600 bg-gray-700 bg-opacity-80 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
|
||||
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
|
||||
>
|
||||
<PencilIcon className="h-full w-full" />
|
||||
</button>
|
||||
@@ -171,7 +171,7 @@ const Discover = () => {
|
||||
leave="transition duration-300 transform"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-6"
|
||||
className="fixed bottom-0 right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
>
|
||||
<Button
|
||||
buttonType="default"
|
||||
|
206
src/components/Layout/MobileMenu/index.tsx
Normal file
206
src/components/Layout/MobileMenu/index.tsx
Normal file
@@ -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;
|
@@ -17,7 +17,7 @@ import { useRouter } from 'next/router';
|
||||
import { Fragment, useRef } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const menuMessages = defineMessages({
|
||||
dashboard: 'Discover',
|
||||
browsemovies: 'Movies',
|
||||
browsetv: 'Series',
|
||||
@@ -35,7 +35,7 @@ interface SidebarProps {
|
||||
interface SidebarLinkProps {
|
||||
href: string;
|
||||
svgIcon: React.ReactNode;
|
||||
messagesKey: keyof typeof messages;
|
||||
messagesKey: keyof typeof menuMessages;
|
||||
activeRegExp: RegExp;
|
||||
as?: string;
|
||||
requiredPermission?: Permission | Permission[];
|
||||
@@ -192,7 +192,7 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
||||
>
|
||||
{sidebarLink.svgIcon}
|
||||
{intl.formatMessage(
|
||||
messages[sidebarLink.messagesKey]
|
||||
menuMessages[sidebarLink.messagesKey]
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
@@ -253,7 +253,9 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
||||
data-testid={sidebarLink.dataTestId}
|
||||
>
|
||||
{sidebarLink.svgIcon}
|
||||
{intl.formatMessage(messages[sidebarLink.messagesKey])}
|
||||
{intl.formatMessage(
|
||||
menuMessages[sidebarLink.messagesKey]
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import MobileMenu from '@app/components/Layout/MobileMenu';
|
||||
import SearchInput from '@app/components/Layout/SearchInput';
|
||||
import Sidebar from '@app/components/Layout/Sidebar';
|
||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||
@@ -6,8 +7,7 @@ import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/solid';
|
||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -56,6 +56,9 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
|
||||
</div>
|
||||
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
||||
<div className="sm:hidden">
|
||||
<MobileMenu />
|
||||
</div>
|
||||
|
||||
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
|
||||
<PullToRefresh />
|
||||
@@ -68,17 +71,17 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={`px-4 text-white ${
|
||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||
} transition duration-300 focus:outline-none lg:hidden`}
|
||||
aria-label="Open sidebar"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
data-testid="sidebar-toggle"
|
||||
>
|
||||
<Bars3BottomLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-between pr-4 md:pr-4 md:pl-4">
|
||||
<div className="flex flex-1 items-center justify-between px-4 md:pr-4 md:pl-4">
|
||||
<button
|
||||
className={`mr-2 hidden text-white sm:block ${
|
||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||
} transition duration-300 focus:outline-none lg:hidden`}
|
||||
aria-label="Open sidebar"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
data-testid="sidebar-toggle"
|
||||
>
|
||||
<Bars3BottomLeftIcon className="h-7 w-7" />
|
||||
</button>
|
||||
<button
|
||||
className={`mr-2 text-white ${
|
||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||
|
@@ -834,7 +834,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
linkUrl={`/movie/${data.id}/similar`}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
<div className="relative h-32 sm:h-8" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -1016,7 +1016,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
linkUrl={`/tv/${data.id}/similar`}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
<div className="relative h-32 sm:h-8" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -6,7 +6,13 @@
|
||||
html {
|
||||
min-height: calc(100% + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||
env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
calc(4rem + env(safe-area-inset-bottom)) env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
html {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -460,6 +466,18 @@
|
||||
top: calc(-4rem - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.absolute-bottom-shift {
|
||||
bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.safe-shift-edit-menu {
|
||||
bottom: calc(54px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.padding-bottom-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.min-h-screen-shift {
|
||||
min-height: calc(100vh + env(safe-area-inset-top));
|
||||
}
|
||||
|
Reference in New Issue
Block a user