1
0
Fork 0
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:
Bill Thornton 2022-11-28 16:39:13 -05:00
parent bf62e7a15d
commit 27776d57fc
15 changed files with 977 additions and 11 deletions

View file

@ -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' />

View file

@ -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 && (
<>

View 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;

View 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;

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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';

View 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'
}