mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Add drawer to experimental layout
This commit is contained in:
parent
bf62e7a15d
commit
27776d57fc
15 changed files with 977 additions and 11 deletions
|
@ -1,23 +1,56 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import AppHeader from 'components/AppHeader';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
|
||||
import AppToolbar from './components/AppToolbar';
|
||||
import AppUserMenu from './components/AppUserMenu';
|
||||
import AppDrawer, { DRAWER_WIDTH, isDrawerPath } from './components/drawers/AppDrawer';
|
||||
import ElevationScroll from './components/ElevationScroll';
|
||||
import { ExperimentalAppRoutes } from './routes/AppRoutes';
|
||||
import theme from './theme';
|
||||
|
||||
import './AppOverrides.scss';
|
||||
|
||||
interface ExperimentalAppSettings {
|
||||
isDrawerPinned: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_EXPERIMENTAL_APP_SETTINGS: ExperimentalAppSettings = {
|
||||
isDrawerPinned: false
|
||||
};
|
||||
|
||||
const ExperimentalApp = () => {
|
||||
const [ appSettings, setAppSettings ] = useLocalStorage<ExperimentalAppSettings>('ExperimentalAppSettings', DEFAULT_EXPERIMENTAL_APP_SETTINGS);
|
||||
const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned);
|
||||
const { user } = useApi();
|
||||
const location = useLocation();
|
||||
|
||||
const isDrawerAvailable = isDrawerPath(location.pathname);
|
||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user);
|
||||
|
||||
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isUserMenuOpen = Boolean(userMenuAnchorEl);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDrawerActive !== appSettings.isDrawerPinned) {
|
||||
setAppSettings({
|
||||
...appSettings,
|
||||
isDrawerPinned: isDrawerActive
|
||||
});
|
||||
}
|
||||
}, [ appSettings, isDrawerActive, setAppSettings ]);
|
||||
|
||||
const onToggleDrawer = useCallback(() => {
|
||||
setIsDrawerActive(!isDrawerActive);
|
||||
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||
|
||||
const onUserButtonClick = useCallback((event) => {
|
||||
setUserMenuAnchorEl(event.currentTarget);
|
||||
}, [ setUserMenuAnchorEl ]);
|
||||
|
@ -39,17 +72,25 @@ const ExperimentalApp = () => {
|
|||
</div>
|
||||
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<ElevationScroll>
|
||||
<ElevationScroll elevate={isDrawerOpen}>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
|
||||
>
|
||||
<AppToolbar
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onToggleDrawer}
|
||||
onUserButtonClick={onUserButtonClick}
|
||||
/>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
|
@ -59,7 +100,17 @@ const ExperimentalApp = () => {
|
|||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen
|
||||
}),
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: `-${DRAWER_WIDTH}px`
|
||||
},
|
||||
...(isDrawerActive && {
|
||||
transition: theme.transitions.create('margin', {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen
|
||||
}),
|
||||
marginLeft: 0
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='mainAnimatedPages skinBody' />
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
|
@ -7,24 +8,32 @@ import Toolbar from '@mui/material/Toolbar';
|
|||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import { ID as UserMenuId } from './AppUserMenu';
|
||||
import AppTabs from './tabs/AppTabs';
|
||||
import { isDrawerPath } from './drawers/AppDrawer';
|
||||
|
||||
interface AppToolbarProps {
|
||||
isDrawerOpen: boolean
|
||||
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
onUserButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const AppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerOpen,
|
||||
onDrawerButtonClick,
|
||||
onUserButtonClick
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { api, user } = useApi();
|
||||
const isUserLoggedIn = Boolean(user);
|
||||
const location = useLocation();
|
||||
|
||||
const isDrawerAvailable = isDrawerPath(location.pathname);
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
|
@ -36,6 +45,21 @@ const AppToolbar: FC<AppToolbarProps> = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{isUserLoggedIn && isDrawerAvailable && (
|
||||
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
|
||||
<IconButton
|
||||
size='large'
|
||||
edge='start'
|
||||
color='inherit'
|
||||
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
|
||||
sx={{ mr: 2 }}
|
||||
onClick={onDrawerButtonClick}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={Link}
|
||||
to='/'
|
||||
|
@ -64,7 +88,7 @@ const AppToolbar: FC<AppToolbarProps> = ({
|
|||
</Typography>
|
||||
</Box>
|
||||
|
||||
<AppTabs />
|
||||
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||
|
||||
{isUserLoggedIn && (
|
||||
<>
|
||||
|
|
50
src/apps/experimental/components/LibraryIcon.tsx
Normal file
50
src/apps/experimental/components/LibraryIcon.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import Movie from '@mui/icons-material/Movie';
|
||||
import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import Photo from '@mui/icons-material/Photo';
|
||||
import LiveTv from '@mui/icons-material/LiveTv';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
import Theaters from '@mui/icons-material/Theaters';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Book from '@mui/icons-material/Book';
|
||||
import Collections from '@mui/icons-material/Collections';
|
||||
import Queue from '@mui/icons-material/Queue';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import React, { FC } from 'react';
|
||||
import { CollectionType } from 'types/collectionType';
|
||||
|
||||
interface LibraryIconProps {
|
||||
item: BaseItemDto
|
||||
}
|
||||
|
||||
const LibraryIcon: FC<LibraryIconProps> = ({
|
||||
item
|
||||
}) => {
|
||||
switch (item.CollectionType) {
|
||||
case CollectionType.Movies:
|
||||
return <Movie />;
|
||||
case CollectionType.Music:
|
||||
return <MusicNote />;
|
||||
case CollectionType.HomeVideos:
|
||||
case CollectionType.Photos:
|
||||
return <Photo />;
|
||||
case CollectionType.LiveTv:
|
||||
return <LiveTv />;
|
||||
case CollectionType.TvShows:
|
||||
return <Tv />;
|
||||
case CollectionType.Trailers:
|
||||
return <Theaters />;
|
||||
case CollectionType.MusicVideos:
|
||||
return <MusicVideo />;
|
||||
case CollectionType.Books:
|
||||
return <Book />;
|
||||
case CollectionType.BoxSets:
|
||||
return <Collections />;
|
||||
case CollectionType.Playlists:
|
||||
return <Queue />;
|
||||
default:
|
||||
return <Folder />;
|
||||
}
|
||||
};
|
||||
|
||||
export default LibraryIcon;
|
89
src/apps/experimental/components/drawers/AppDrawer.tsx
Normal file
89
src/apps/experimental/components/drawers/AppDrawer.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React, { FC } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from '../../routes/asyncRoutes';
|
||||
import { LEGACY_ADMIN_ROUTES, LEGACY_USER_ROUTES } from '../../routes/legacyRoutes';
|
||||
|
||||
import AdvancedDrawerSection from './dashboard/AdvancedDrawerSection';
|
||||
import DevicesDrawerSection from './dashboard/DevicesDrawerSection';
|
||||
import LiveTvDrawerSection from './dashboard/LiveTvDrawerSection';
|
||||
import PluginDrawerSection from './dashboard/PluginDrawerSection';
|
||||
import ServerDrawerSection from './dashboard/ServerDrawerSection';
|
||||
import MainDrawerContent from './MainDrawerContent';
|
||||
import ResponsiveDrawer, { ResponsiveDrawerProps } from './ResponsiveDrawer';
|
||||
|
||||
export const DRAWER_WIDTH = 240;
|
||||
|
||||
const DRAWERLESS_ROUTES = [
|
||||
'edititemmetadata.html', // metadata manager
|
||||
'video' // video player
|
||||
];
|
||||
|
||||
const MAIN_DRAWER_ROUTES = [
|
||||
...ASYNC_USER_ROUTES,
|
||||
...LEGACY_USER_ROUTES
|
||||
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
|
||||
|
||||
const ADMIN_DRAWER_ROUTES = [
|
||||
...ASYNC_ADMIN_ROUTES,
|
||||
...LEGACY_ADMIN_ROUTES,
|
||||
{ path: '/configurationpage' } // Plugin configuration page
|
||||
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
|
||||
|
||||
/** Utility function to check if a path has a drawer. */
|
||||
export const isDrawerPath = (path: string) => (
|
||||
MAIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
|
||||
|| ADMIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
|
||||
);
|
||||
|
||||
const AppDrawer: FC<ResponsiveDrawerProps> = ({
|
||||
open = false,
|
||||
onClose,
|
||||
onOpen
|
||||
}) => (
|
||||
<Routes>
|
||||
{
|
||||
MAIN_DRAWER_ROUTES.map(route => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<ResponsiveDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<MainDrawerContent />
|
||||
</ResponsiveDrawer>
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
ADMIN_DRAWER_ROUTES.map(route => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<ResponsiveDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<ServerDrawerSection />
|
||||
<DevicesDrawerSection />
|
||||
<LiveTvDrawerSection />
|
||||
<AdvancedDrawerSection />
|
||||
<PluginDrawerSection />
|
||||
</ResponsiveDrawer>
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Suppress warnings for unhandled routes */}
|
||||
<Route path='*' element={null} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
export default AppDrawer;
|
45
src/apps/experimental/components/drawers/ListItemLink.tsx
Normal file
45
src/apps/experimental/components/drawers/ListItemLink.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import ListItemButton, { ListItemButtonBaseProps } from '@mui/material/ListItemButton';
|
||||
import React, { FC } from 'react';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
|
||||
interface ListItemLinkProps extends ListItemButtonBaseProps {
|
||||
to: string
|
||||
}
|
||||
|
||||
const isMatchingParams = (routeParams: URLSearchParams, currentParams: URLSearchParams) => {
|
||||
for (const param of routeParams) {
|
||||
if (currentParams.get(param[0]) !== param[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const ListItemLink: FC<ListItemLinkProps> = ({
|
||||
children,
|
||||
to,
|
||||
...params
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
|
||||
const [ toPath, toParams ] = to.split('?');
|
||||
// eslint-disable-next-line compat/compat
|
||||
const toSearchParams = new URLSearchParams(`?${toParams}`);
|
||||
|
||||
const selected = location.pathname === toPath && (!toParams || isMatchingParams(toSearchParams, searchParams));
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={to}
|
||||
selected={selected}
|
||||
{...params}
|
||||
>
|
||||
{children}
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItemLink;
|
186
src/apps/experimental/components/drawers/MainDrawerContent.tsx
Normal file
186
src/apps/experimental/components/drawers/MainDrawerContent.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import type { SystemInfo } from '@jellyfin/sdk/lib/generated-client/models/system-info';
|
||||
import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import Dashboard from '@mui/icons-material/Dashboard';
|
||||
import Edit from '@mui/icons-material/Edit';
|
||||
import Favorite from '@mui/icons-material/Favorite';
|
||||
import Home from '@mui/icons-material/Home';
|
||||
import Link from '@mui/icons-material/Link';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useWebConfig } from 'hooks/useWebConfig';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
|
||||
import ListItemLink from './ListItemLink';
|
||||
import LibraryIcon from '../LibraryIcon';
|
||||
|
||||
const MainDrawerContent = () => {
|
||||
const { api, user } = useApi();
|
||||
const location = useLocation();
|
||||
const [ systemInfo, setSystemInfo ] = useState<SystemInfo>();
|
||||
const [ userViews, setUserViews ] = useState<BaseItemDto[]>([]);
|
||||
const webConfig = useWebConfig();
|
||||
|
||||
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0');
|
||||
|
||||
useEffect(() => {
|
||||
if (api && user?.Id) {
|
||||
getUserViewsApi(api)
|
||||
.getUserViews({ userId: user.Id })
|
||||
.then(({ data }) => {
|
||||
setUserViews(data.Items || []);
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[MainDrawer] failed to fetch user views', err);
|
||||
setUserViews([]);
|
||||
});
|
||||
|
||||
getSystemApi(api)
|
||||
.getSystemInfo()
|
||||
.then(({ data }) => {
|
||||
setSystemInfo(data);
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[MainDrawer] failed to fetch system info', err);
|
||||
});
|
||||
} else {
|
||||
setUserViews([]);
|
||||
}
|
||||
}, [ api, user?.Id ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* MAIN LINKS */}
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/home.html' selected={isHomeSelected}>
|
||||
<ListItemIcon>
|
||||
<Home />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('Home')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/home.html?tab=1'>
|
||||
<ListItemIcon>
|
||||
<Favorite />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('Favorites')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{/* CUSTOM LINKS */}
|
||||
{(!!webConfig.menuLinks && webConfig.menuLinks.length > 0) && (
|
||||
<>
|
||||
<Divider />
|
||||
<List>
|
||||
{webConfig.menuLinks.map(menuLink => (
|
||||
<ListItem
|
||||
key={`${menuLink.name}_${menuLink.url}`}
|
||||
disablePadding
|
||||
>
|
||||
<ListItemButton
|
||||
component='a'
|
||||
href={menuLink.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<ListItemIcon>
|
||||
{/* TODO: Support custom icons */}
|
||||
<Link />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={menuLink.name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LIBRARY LINKS */}
|
||||
{userViews.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<List
|
||||
aria-labelledby='libraries-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='libraries-subheader'>
|
||||
{globalize.translate('HeaderLibraries')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{userViews.map(view => (
|
||||
<ListItem key={view.Id} disablePadding>
|
||||
<ListItemLink
|
||||
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<LibraryIcon item={view} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={view.Name} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ADMIN LINKS */}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<>
|
||||
<Divider />
|
||||
<List
|
||||
aria-labelledby='admin-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='admin-subheader'>
|
||||
{globalize.translate('HeaderAdmin')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard.html'>
|
||||
<ListItemIcon>
|
||||
<Dashboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabDashboard')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/edititemmetadata.html'>
|
||||
<ListItemIcon>
|
||||
<Edit />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('MetadataManager')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* FOOTER */}
|
||||
<Divider style={{ marginTop: 'auto' }} />
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={systemInfo?.ServerName ? systemInfo.ServerName : 'Jellyfin'}
|
||||
secondary={systemInfo?.Version ? `v${systemInfo.Version}` : ''}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainDrawerContent;
|
|
@ -0,0 +1,86 @@
|
|||
import { Theme } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import browser from 'scripts/browser';
|
||||
|
||||
import { DRAWER_WIDTH } from './AppDrawer';
|
||||
import { isTabPath } from '../tabs/tabRoutes';
|
||||
|
||||
export interface ResponsiveDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
const ResponsiveDrawer: FC<ResponsiveDrawerProps> = ({
|
||||
children,
|
||||
open = false,
|
||||
onClose,
|
||||
onOpen
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
|
||||
const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
|
||||
const isTallToolbar = isTabPath(location.pathname) && !isLargeScreen;
|
||||
|
||||
const getToolbarStyles = useCallback((theme: Theme) => ({
|
||||
marginBottom: isTallToolbar ? theme.spacing(6) : 0
|
||||
}), [ isTallToolbar ]);
|
||||
|
||||
return ( isSmallScreen ? (
|
||||
/* DESKTOP DRAWER */
|
||||
<Drawer
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
}}
|
||||
variant='persistent'
|
||||
anchor='left'
|
||||
open={open}
|
||||
>
|
||||
<Toolbar
|
||||
variant='dense'
|
||||
sx={getToolbarStyles}
|
||||
/>
|
||||
{children}
|
||||
</Drawer>
|
||||
) : (
|
||||
/* MOBILE DRAWER */
|
||||
<SwipeableDrawer
|
||||
anchor='left'
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
// Disable swipe to open on iOS since it interferes with back navigation
|
||||
disableDiscovery={browser.iOS}
|
||||
ModalProps={{
|
||||
keepMounted: true // Better open performance on mobile.
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
variant='dense'
|
||||
sx={getToolbarStyles}
|
||||
/>
|
||||
<Box
|
||||
role='presentation'
|
||||
// Close the drawer when the content is clicked
|
||||
onClick={onClose}
|
||||
onKeyDown={onClose}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</SwipeableDrawer>
|
||||
));
|
||||
};
|
||||
|
||||
export default ResponsiveDrawer;
|
|
@ -0,0 +1,102 @@
|
|||
import { Lan, VpnKey, Article, EditNotifications, Extension, Schedule, ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import ListItemLink from '../ListItemLink';
|
||||
|
||||
const PLUGIN_PATHS = [
|
||||
'/installedplugins.html',
|
||||
'/availableplugins.html',
|
||||
'/repositories.html',
|
||||
'/configurationpage'
|
||||
];
|
||||
|
||||
const AdvancedDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='advanced-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='advanced-subheader'>
|
||||
{globalize.translate('TabAdvanced')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/networking.html'>
|
||||
<ListItemIcon>
|
||||
<Lan />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabNetworking')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/apikeys.html'>
|
||||
<ListItemIcon>
|
||||
<VpnKey />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/log.html'>
|
||||
<ListItemIcon>
|
||||
<Article />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabLogs')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/notificationsettings.html'>
|
||||
<ListItemIcon>
|
||||
<EditNotifications />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabNotifications')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/installedplugins.html' selected={false}>
|
||||
<ListItemIcon>
|
||||
<Extension />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabPlugins')} />
|
||||
{isPluginSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/installedplugins.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/availableplugins.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabCatalog')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/repositories.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabRepositories')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/scheduledtasks.html'>
|
||||
<ListItemIcon>
|
||||
<Schedule />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabScheduledTasks')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedDrawerSection;
|
|
@ -0,0 +1,73 @@
|
|||
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import ListItemLink from '../ListItemLink';
|
||||
|
||||
const DLNA_PATHS = [
|
||||
'/dlnasettings.html',
|
||||
'/dlnaprofiles.html'
|
||||
];
|
||||
|
||||
const DevicesDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='devices-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='devices-subheader'>
|
||||
{globalize.translate('HeaderDevices')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/devices.html'>
|
||||
<ListItemIcon>
|
||||
<Devices />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderDevices')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/serveractivity.html'>
|
||||
<ListItemIcon>
|
||||
<Analytics />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderActivity')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dlnasettings.html' selected={false}>
|
||||
<ListItemIcon>
|
||||
<Input />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'DLNA'} />
|
||||
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/dlnasettings.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Settings')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dlnaprofiles.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabProfiles')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevicesDrawerSection;
|
|
@ -0,0 +1,43 @@
|
|||
import { Dvr, LiveTv } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import ListItemLink from '../ListItemLink';
|
||||
|
||||
const LiveTvDrawerSection = () => {
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='livetv-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='livetv-subheader'>
|
||||
{globalize.translate('LiveTV')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/livetvstatus.html'>
|
||||
<ListItemIcon>
|
||||
<LiveTv />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('LiveTV')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/livetvsettings.html'>
|
||||
<ListItemIcon>
|
||||
<Dvr />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderDVR')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveTvDrawerSection;
|
|
@ -0,0 +1,67 @@
|
|||
import { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||
import { Folder } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
|
||||
import ListItemLink from '../ListItemLink';
|
||||
|
||||
const PluginDrawerSection = () => {
|
||||
const { api } = useApi();
|
||||
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPluginPages = async () => {
|
||||
if (!api) return;
|
||||
|
||||
const pagesResponse = await getDashboardApi(api)
|
||||
.getConfigurationPages({ enableInMainMenu: true });
|
||||
|
||||
setPagesInfo(pagesResponse.data);
|
||||
};
|
||||
|
||||
fetchPluginPages()
|
||||
.catch(err => {
|
||||
console.error('[PluginDrawerSection] unable to fetch plugin config pages', err);
|
||||
});
|
||||
}, [ api ]);
|
||||
|
||||
if (!api || pagesInfo.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='plugins-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='plugins-subheader'>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{
|
||||
pagesInfo.map(pageInfo => (
|
||||
<ListItem key={pageInfo.PluginId} disablePadding>
|
||||
<ListItemLink to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}>
|
||||
<ListItemIcon>
|
||||
{/* TODO: Support different icons? */}
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={pageInfo.DisplayName} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
))
|
||||
}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginDrawerSection;
|
|
@ -0,0 +1,118 @@
|
|||
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import ListItemLink from '../ListItemLink';
|
||||
|
||||
const LIBRARY_PATHS = [
|
||||
'/library.html',
|
||||
'/librarydisplay.html',
|
||||
'/metadataimages.html',
|
||||
'/metadatanfo.html'
|
||||
];
|
||||
|
||||
const PLAYBACK_PATHS = [
|
||||
'/encodingsettings.html',
|
||||
'/playbackconfiguration.html',
|
||||
'/streamingsettings.html'
|
||||
];
|
||||
|
||||
const ServerDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
|
||||
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='server-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='server-subheader'>
|
||||
{globalize.translate('TabServer')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard.html'>
|
||||
<ListItemIcon>
|
||||
<Dashboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabDashboard')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboardgeneral.html'>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('General')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/userprofiles.html'>
|
||||
<ListItemIcon>
|
||||
<People />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderUsers')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/library.html' selected={false}>
|
||||
<ListItemIcon>
|
||||
<LibraryAdd />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderLibraries')} />
|
||||
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/library.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('HeaderLibraries')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/librarydisplay.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Display')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/metadataimages.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Metadata')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/metadatanfo.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/encodingsettings.html' selected={false}>
|
||||
<ListItemIcon>
|
||||
<PlayCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TitlePlayback')} />
|
||||
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/encodingsettings.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Transcoding')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/playbackconfiguration.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('ButtonResume')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/streamingsettings.html' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabStreaming')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerDrawerSection;
|
|
@ -2,12 +2,21 @@ import { Theme } from '@mui/material/styles';
|
|||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { debounce } from 'lodash-es';
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import TabRoutes, { getDefaultTabIndex } from './tabRoutes';
|
||||
|
||||
const AppTabs: FC = () => {
|
||||
interface AppTabsParams {
|
||||
isDrawerOpen: boolean
|
||||
}
|
||||
|
||||
const handleResize = debounce(() => window.dispatchEvent(new Event('resize')), 100);
|
||||
|
||||
const AppTabs: FC<AppTabsParams> = ({
|
||||
isDrawerOpen
|
||||
}) => {
|
||||
const isBigScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
|
||||
const location = useLocation();
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
|
@ -18,6 +27,12 @@ const AppTabs: FC = () => {
|
|||
parseInt(searchParamsTab, 10) :
|
||||
getDefaultTabIndex(location.pathname, libraryId);
|
||||
|
||||
// HACK: Force resizing to workaround upstream bug with tab resizing
|
||||
// https://github.com/mui/material-ui/issues/24011
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [ isDrawerOpen ]);
|
||||
|
||||
const onTabClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import ConnectionRequired from '../../../components/ConnectionRequired';
|
||||
import ServerContentPage from '../../../components/ServerContentPage';
|
||||
import { toAsyncPageRoute } from '../../../components/router/AsyncRoute';
|
||||
import { toViewManagerPageRoute } from '../../../components/router/LegacyRoute';
|
||||
import ConnectionRequired from 'components/ConnectionRequired';
|
||||
import ServerContentPage from 'components/ServerContentPage';
|
||||
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||
|
||||
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './asyncRoutes';
|
||||
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './legacyRoutes';
|
||||
|
||||
|
|
16
src/types/collectionType.ts
Normal file
16
src/types/collectionType.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
// NOTE: This should be included in the OpenAPI spec ideally
|
||||
// https://github.com/jellyfin/jellyfin/blob/47290a8c3665f3adb859bda19deb66f438f2e5d0/MediaBrowser.Model/Entities/CollectionType.cs
|
||||
export enum CollectionType {
|
||||
Movies = 'movies',
|
||||
TvShows = 'tvshows',
|
||||
Music = 'music',
|
||||
MusicVideos = 'musicvideos',
|
||||
Trailers = 'trailers',
|
||||
HomeVideos = 'homevideos',
|
||||
BoxSets = 'boxsets',
|
||||
Books = 'books',
|
||||
Photos = 'photos',
|
||||
LiveTv = 'livetv',
|
||||
Playlists = 'playlists',
|
||||
Folders = 'folders'
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue