mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Redesign library navigation in experimental layout
This commit is contained in:
parent
27e92802d5
commit
05c72ad521
22 changed files with 524 additions and 240 deletions
|
@ -17,7 +17,7 @@ import { createRouterHistory } from 'components/router/routerHistory';
|
|||
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
const isExperimentalLayout = layoutMode === 'experimental';
|
||||
const isExperimentalLayout = layoutMode === 'experimental' || !layoutMode;
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Outlet, useLocation } from 'react-router-dom';
|
|||
|
||||
import AppBody from 'components/AppBody';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ServerButton from 'components/toolbar/ServerButton';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import ThemeCss from 'components/ThemeCss';
|
||||
|
@ -22,8 +23,6 @@ import { DASHBOARD_APP_PATHS } from './routes/routes';
|
|||
|
||||
import './AppOverrides.scss';
|
||||
|
||||
const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
|
||||
|
||||
export const Component: FC = () => {
|
||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||
const location = useLocation();
|
||||
|
@ -31,8 +30,8 @@ export const Component: FC = () => {
|
|||
const { dateFnsLocale } = useLocale();
|
||||
|
||||
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
||||
const isDrawerAvailable = Boolean(user)
|
||||
&& !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
|
||||
const isMetadataManager = location.pathname.startsWith(`/${DASHBOARD_APP_PATHS.MetadataManager}`);
|
||||
const isDrawerAvailable = Boolean(user) && !isMetadataManager;
|
||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
||||
|
||||
const onToggleDrawer = useCallback(() => {
|
||||
|
@ -74,6 +73,10 @@ export const Component: FC = () => {
|
|||
<HelpButton />
|
||||
}
|
||||
>
|
||||
{isMetadataManager && (
|
||||
<ServerButton />
|
||||
)}
|
||||
|
||||
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||
</AppToolbar>
|
||||
</AppBar>
|
||||
|
|
|
@ -8,7 +8,6 @@ import { Outlet, useLocation } from 'react-router-dom';
|
|||
import AppBody from 'components/AppBody';
|
||||
import CustomCss from 'components/CustomCss';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import ThemeCss from 'components/ThemeCss';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
|
@ -23,7 +22,7 @@ export const Component = () => {
|
|||
const location = useLocation();
|
||||
|
||||
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
||||
const isDrawerAvailable = isDrawerPath(location.pathname) && Boolean(user);
|
||||
const isDrawerAvailable = isDrawerPath(location.pathname) && Boolean(user) && !isMediumScreen;
|
||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
||||
|
||||
const onToggleDrawer = useCallback(() => {
|
||||
|
@ -38,14 +37,8 @@ export const Component = () => {
|
|||
<AppBar
|
||||
position='fixed'
|
||||
sx={{
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
|
||||
},
|
||||
ml: {
|
||||
xs: 0,
|
||||
md: isDrawerAvailable ? DRAWER_WIDTH : 0
|
||||
}
|
||||
width: '100%',
|
||||
ml: 0
|
||||
}}
|
||||
>
|
||||
<AppToolbar
|
||||
|
|
|
@ -5,23 +5,10 @@ $mui-bp-md: 900px;
|
|||
$mui-bp-lg: 1200px;
|
||||
$mui-bp-xl: 1536px;
|
||||
|
||||
$drawer-width: 240px;
|
||||
|
||||
#reactRoot {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Fix main pages layout to work with drawer
|
||||
.mainAnimatedPage {
|
||||
@media all and (min-width: $mui-bp-md) {
|
||||
left: $drawer-width;
|
||||
}
|
||||
}
|
||||
// The fallback page has no drawer
|
||||
#fallbackPage {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
// Hide some items from the user "settings" page that are in the drawer
|
||||
#myPreferencesMenuPage {
|
||||
.lnkQuickConnectPreferences,
|
||||
|
@ -35,19 +22,10 @@ $drawer-width: 240px;
|
|||
.homePage.libraryPage.withTabs, // Home page
|
||||
// Library pages excluding the item details page and tabbed pages
|
||||
.libraryPage:not(
|
||||
.itemDetailPage,
|
||||
.withTabs
|
||||
.itemDetailPage
|
||||
) {
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
// Tabbed library pages
|
||||
.libraryPage.withTabs {
|
||||
padding-top: 6.5rem !important;
|
||||
|
||||
@media all and (min-width: $mui-bp-lg) {
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix backdrop position on mobile item details page
|
||||
.layout-mobile .itemBackdrop {
|
||||
|
|
|
@ -27,21 +27,17 @@ const getUrlParams = (searchParams: URLSearchParams) => {
|
|||
return params;
|
||||
};
|
||||
|
||||
interface SearchButtonProps {
|
||||
isTabsAvailable: boolean;
|
||||
}
|
||||
|
||||
const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
|
||||
const SearchButton: FC = () => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isSearchPath = location.pathname === '/search';
|
||||
const createSearchLink = isTabsAvailable ?
|
||||
const search = createSearchParams(getUrlParams(searchParams));
|
||||
const createSearchLink =
|
||||
{
|
||||
pathname: '/search',
|
||||
search: `?${createSearchParams(getUrlParams(searchParams))}`
|
||||
} :
|
||||
'/search';
|
||||
search: search ? `?${search}` : undefined
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={globalize.translate('Search')}>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import Stack from '@mui/material/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import AppTabs from '../tabs/AppTabs';
|
||||
import ServerButton from 'components/toolbar/ServerButton';
|
||||
|
||||
import RemotePlayButton from './RemotePlayButton';
|
||||
import SyncPlayButton from './SyncPlayButton';
|
||||
import SearchButton from './SearchButton';
|
||||
import { isTabPath } from '../tabs/tabRoutes';
|
||||
import UserViewNav from './userViews/UserViewNav';
|
||||
|
||||
interface AppToolbarProps {
|
||||
isDrawerAvailable: boolean
|
||||
|
@ -31,7 +34,6 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
|||
// The video osd does not show the standard toolbar
|
||||
if (location.pathname === '/video') return null;
|
||||
|
||||
const isTabsAvailable = isTabPath(location.pathname);
|
||||
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
|
@ -40,7 +42,7 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
|||
<>
|
||||
<SyncPlayButton />
|
||||
<RemotePlayButton />
|
||||
<SearchButton isTabsAvailable={isTabsAvailable} />
|
||||
<SearchButton />
|
||||
</>
|
||||
)}
|
||||
isDrawerAvailable={isDrawerAvailable}
|
||||
|
@ -48,7 +50,18 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
|||
onDrawerButtonClick={onDrawerButtonClick}
|
||||
isUserMenuAvailable={!isPublicPath}
|
||||
>
|
||||
{isTabsAvailable && (<AppTabs isDrawerOpen={isDrawerOpen} />)}
|
||||
{!isDrawerAvailable && (
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={0.5}
|
||||
>
|
||||
<ServerButton />
|
||||
|
||||
{!isPublicPath && (
|
||||
<UserViewNav />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</AppToolbar>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
|
||||
import Favorite from '@mui/icons-material/Favorite';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import LibraryIcon from 'apps/experimental/components/LibraryIcon';
|
||||
import { isTabPath } from 'apps/experimental/components/tabs/tabRoutes';
|
||||
import { PseudoUserViews } from 'apps/experimental/constants/PseudoUserViews';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import useCurrentTab from 'hooks/useCurrentTab';
|
||||
import { useUserViews } from 'hooks/useUserViews';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import UserViewsMenu from './UserViewsMenu';
|
||||
|
||||
const MAX_USER_VIEWS_MD = 3;
|
||||
const MAX_USER_VIEWS_LG = 5;
|
||||
const MAX_USER_VIEWS_XL = 8;
|
||||
|
||||
const OVERFLOW_MENU_ID = 'user-view-overflow-menu';
|
||||
|
||||
const HOME_PATH = '/home';
|
||||
const LIST_PATH = '/list';
|
||||
|
||||
const getCurrentUserView = (
|
||||
userViews: BaseItemDto[] | undefined,
|
||||
pathname: string,
|
||||
libraryId: string | null,
|
||||
collectionType: string | null,
|
||||
tab: number
|
||||
) => {
|
||||
const isUserViewPath = isTabPath(pathname) || [HOME_PATH, LIST_PATH].includes(pathname);
|
||||
if (!isUserViewPath) return undefined;
|
||||
|
||||
if (collectionType === CollectionType.Livetv) {
|
||||
return userViews?.find(({ CollectionType: type }) => type === CollectionType.Livetv);
|
||||
}
|
||||
|
||||
if (pathname === HOME_PATH && tab === 1) {
|
||||
return PseudoUserViews.Favorites;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison
|
||||
return userViews?.find(({ Id: id }) => id === libraryId);
|
||||
};
|
||||
|
||||
const UserViewNav = () => {
|
||||
const location = useLocation();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const libraryId = searchParams.get('topParentId') || searchParams.get('parentId');
|
||||
const collectionType = searchParams.get('collectionType');
|
||||
const { activeTab } = useCurrentTab();
|
||||
|
||||
const isExtraLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('xl'));
|
||||
const isLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('lg'));
|
||||
const maxViews = useMemo(() => {
|
||||
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
|
||||
if (isLargeScreen) return MAX_USER_VIEWS_LG;
|
||||
return MAX_USER_VIEWS_MD;
|
||||
}, [ isExtraLargeScreen, isLargeScreen ]);
|
||||
|
||||
const { user } = useApi();
|
||||
const {
|
||||
data: userViews,
|
||||
isPending
|
||||
} = useUserViews(user?.Id);
|
||||
|
||||
const primaryViews = useMemo(() => (
|
||||
userViews?.Items?.slice(0, maxViews)
|
||||
), [ maxViews, userViews ]);
|
||||
|
||||
const overflowViews = useMemo(() => (
|
||||
userViews?.Items?.slice(maxViews)
|
||||
), [ maxViews, userViews ]);
|
||||
|
||||
const [ overflowAnchorEl, setOverflowAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isOverflowMenuOpen = Boolean(overflowAnchorEl);
|
||||
|
||||
const onOverflowButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
setOverflowAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const onOverflowMenuClose = useCallback(() => {
|
||||
setOverflowAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const currentUserView = useMemo(() => (
|
||||
getCurrentUserView(userViews?.Items, location.pathname, libraryId, collectionType, activeTab)
|
||||
), [ activeTab, collectionType, libraryId, location.pathname, userViews ]);
|
||||
|
||||
if (isPending) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='text'
|
||||
color={(currentUserView?.Id === PseudoUserViews.Favorites.Id) ? 'primary' : 'inherit'}
|
||||
startIcon={<Favorite />}
|
||||
component={Link}
|
||||
to='/home.html?tab=1'
|
||||
>
|
||||
{globalize.translate(PseudoUserViews.Favorites.Name)}
|
||||
</Button>
|
||||
|
||||
{primaryViews?.map(view => (
|
||||
<Button
|
||||
key={view.Id}
|
||||
variant='text'
|
||||
color={(view.Id === currentUserView?.Id) ? 'primary' : 'inherit'}
|
||||
startIcon={<LibraryIcon item={view} />}
|
||||
component={Link}
|
||||
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
|
||||
>
|
||||
{view.Name}
|
||||
</Button>
|
||||
))}
|
||||
{overflowViews && overflowViews.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant='text'
|
||||
color='inherit'
|
||||
endIcon={<ArrowDropDown />}
|
||||
aria-controls={OVERFLOW_MENU_ID}
|
||||
aria-haspopup='true'
|
||||
onClick={onOverflowButtonClick}
|
||||
>
|
||||
{globalize.translate('ButtonMore')}
|
||||
</Button>
|
||||
|
||||
<UserViewsMenu
|
||||
anchorEl={overflowAnchorEl}
|
||||
id={OVERFLOW_MENU_ID}
|
||||
open={isOverflowMenuOpen}
|
||||
onMenuClose={onOverflowMenuClose}
|
||||
userViews={overflowViews}
|
||||
selectedId={currentUserView?.Id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserViewNav;
|
|
@ -0,0 +1,51 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText/ListItemText';
|
||||
import Menu, { type MenuProps } from '@mui/material/Menu/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem/MenuItem';
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import LibraryIcon from 'apps/experimental/components/LibraryIcon';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
|
||||
interface UserViewsMenuProps extends MenuProps {
|
||||
userViews: BaseItemDto[]
|
||||
selectedId?: string
|
||||
includeGlobalViews?: boolean
|
||||
onMenuClose: () => void
|
||||
}
|
||||
|
||||
const UserViewsMenu: FC<UserViewsMenuProps> = ({
|
||||
userViews,
|
||||
selectedId,
|
||||
onMenuClose,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Menu
|
||||
{...props}
|
||||
keepMounted
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{userViews.map(view => (
|
||||
<MenuItem
|
||||
key={view.Id}
|
||||
component={Link}
|
||||
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
|
||||
onClick={onMenuClose}
|
||||
selected={view.Id === selectedId}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<LibraryIcon item={view} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{view.Name}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserViewsMenu;
|
|
@ -1,5 +1,6 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import Favorite from '@mui/icons-material/Favorite';
|
||||
import Movie from '@mui/icons-material/Movie';
|
||||
import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import Photo from '@mui/icons-material/Photo';
|
||||
|
@ -14,6 +15,8 @@ import VideoLibrary from '@mui/icons-material/VideoLibrary';
|
|||
import Folder from '@mui/icons-material/Folder';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import { PseudoUserViews } from '../constants/PseudoUserViews';
|
||||
|
||||
interface LibraryIconProps {
|
||||
item: BaseItemDto
|
||||
}
|
||||
|
@ -21,6 +24,10 @@ interface LibraryIconProps {
|
|||
const LibraryIcon: FC<LibraryIconProps> = ({
|
||||
item
|
||||
}) => {
|
||||
if (item.Id === PseudoUserViews.Favorites.Id) {
|
||||
return <Favorite />;
|
||||
}
|
||||
|
||||
switch (item.CollectionType) {
|
||||
case CollectionType.Movies:
|
||||
return <Movie />;
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
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 Divider from '@mui/material/Divider';
|
||||
|
@ -111,38 +109,6 @@ const MainDrawerContent = () => {
|
|||
</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'>
|
||||
<ListItemIcon>
|
||||
<Dashboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabDashboard')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/metadata'>
|
||||
<ListItemIcon>
|
||||
<Edit />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('MetadataManager')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,6 +14,15 @@ import { CardShape } from 'utils/card';
|
|||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import Lists from 'components/listview/List/Lists';
|
||||
import Cards from 'components/cardbuilder/Card/Cards';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
import { useItem } from 'hooks/useItem';
|
||||
|
||||
import AlphabetPicker from './AlphabetPicker';
|
||||
import FilterButton from './filter/FilterButton';
|
||||
import NewCollectionButton from './NewCollectionButton';
|
||||
|
@ -23,14 +32,7 @@ import QueueButton from './QueueButton';
|
|||
import ShuffleButton from './ShuffleButton';
|
||||
import SortButton from './SortButton';
|
||||
import GridListViewButton from './GridListViewButton';
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import Lists from 'components/listview/List/Lists';
|
||||
import Cards from 'components/cardbuilder/Card/Cards';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
import { useItem } from 'hooks/useItem';
|
||||
import LibraryViewMenu from './LibraryViewMenu';
|
||||
|
||||
interface ItemsViewProps {
|
||||
viewType: LibraryTab;
|
||||
|
@ -225,17 +227,21 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
'vertical-list' :
|
||||
'vertical-wrap'
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
|
||||
{isPaginationEnabled && (
|
||||
<Pagination
|
||||
totalRecordCount={totalRecordCount}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
<Box
|
||||
className={classNames(
|
||||
'padded-top padded-left padded-right padded-bottom',
|
||||
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
|
||||
)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
|
||||
<LibraryViewMenu />
|
||||
|
||||
{isBtnPlayAllEnabled && (
|
||||
<PlayAllButton
|
||||
|
@ -246,6 +252,15 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnShuffleEnabled && totalRecordCount > 1 && (
|
||||
<ShuffleButton
|
||||
item={item}
|
||||
items={items}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnQueueEnabled
|
||||
&& item
|
||||
&& playbackManager.canQueue(item) && (
|
||||
|
@ -255,15 +270,6 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
hasFilters={hasFilters}
|
||||
/>
|
||||
)}
|
||||
{isBtnShuffleEnabled && totalRecordCount > 1 && (
|
||||
<ShuffleButton
|
||||
item={item}
|
||||
items={items}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnSortEnabled && (
|
||||
<SortButton
|
||||
viewType={viewType}
|
||||
|
@ -289,6 +295,24 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPaginationEnabled && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexGrow: 1,
|
||||
order: 10
|
||||
}}
|
||||
>
|
||||
<Pagination
|
||||
totalRecordCount={totalRecordCount}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isAlphabetPickerEnabled && hasSortName && (
|
||||
|
@ -312,7 +336,16 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
)}
|
||||
|
||||
{isPaginationEnabled && (
|
||||
<Box className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
|
||||
<Box
|
||||
className={classNames(
|
||||
'padded-top padded-left padded-right padded-bottom',
|
||||
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
|
||||
)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Pagination
|
||||
totalRecordCount={totalRecordCount}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
|
|
77
src/apps/experimental/components/library/LibraryViewMenu.tsx
Normal file
77
src/apps/experimental/components/library/LibraryViewMenu.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
|
||||
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Menu from '@mui/material/Menu/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem/MenuItem';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import useCurrentTab from 'hooks/useCurrentTab';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import TabRoutes from '../tabs/tabRoutes';
|
||||
|
||||
const LIBRARY_VIEW_MENU_ID = 'library-view-menu';
|
||||
|
||||
const LibraryViewMenu: FC = () => {
|
||||
const location = useLocation();
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
const { activeTab } = useCurrentTab();
|
||||
|
||||
const [ menuAnchorEl, setMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isMenuOpen = Boolean(menuAnchorEl);
|
||||
|
||||
const onMenuButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setMenuAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const currentRoute = TabRoutes.find(({ path }) => path === location.pathname);
|
||||
const currentTab = currentRoute?.tabs.find(({ index }) => index === activeTab);
|
||||
|
||||
if (!currentTab) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='text'
|
||||
size='large'
|
||||
color='inherit'
|
||||
endIcon={<ArrowDropDown />}
|
||||
aria-controls={LIBRARY_VIEW_MENU_ID}
|
||||
aria-haspopup='true'
|
||||
onClick={onMenuButtonClick}
|
||||
>
|
||||
{globalize.translate(currentTab.label)}
|
||||
</Button>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchorEl}
|
||||
id={LIBRARY_VIEW_MENU_ID}
|
||||
keepMounted
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{currentRoute?.tabs.map(tab => (
|
||||
<MenuItem
|
||||
key={tab.value}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => {
|
||||
searchParams.set('tab', `${tab.index}`);
|
||||
setSearchParams(searchParams);
|
||||
onMenuClose();
|
||||
}}
|
||||
selected={tab.index === currentTab.index}
|
||||
>
|
||||
{globalize.translate(tab.label)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryViewMenu;
|
|
@ -1,3 +1,4 @@
|
|||
import Box from '@mui/material/Box/Box';
|
||||
import React, { type FC } from 'react';
|
||||
import SuggestionsSectionView from './SuggestionsSectionView';
|
||||
import UpcomingView from './UpcomingView';
|
||||
|
@ -8,6 +9,7 @@ import ProgramsSectionView from './ProgramsSectionView';
|
|||
import { LibraryTab } from 'types/libraryTab';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { LibraryTabContent } from 'types/libraryTabContent';
|
||||
import LibraryViewMenu from './LibraryViewMenu';
|
||||
|
||||
interface PageTabContentProps {
|
||||
parentId: ParentId;
|
||||
|
@ -17,46 +19,86 @@ interface PageTabContentProps {
|
|||
const PageTabContent: FC<PageTabContentProps> = ({ parentId, currentTab }) => {
|
||||
if (currentTab.viewType === LibraryTab.Suggestions) {
|
||||
return (
|
||||
<SuggestionsSectionView
|
||||
parentId={parentId}
|
||||
sectionType={
|
||||
currentTab.sectionsView?.suggestionSections ?? []
|
||||
}
|
||||
isMovieRecommendationEnabled={
|
||||
currentTab.sectionsView?.isMovieRecommendations
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Box className='padded-top padded-left padded-right padded-bottom'>
|
||||
<LibraryViewMenu />
|
||||
</Box>
|
||||
|
||||
<SuggestionsSectionView
|
||||
parentId={parentId}
|
||||
sectionType={
|
||||
currentTab.sectionsView?.suggestionSections ?? []
|
||||
}
|
||||
isMovieRecommendationEnabled={
|
||||
currentTab.sectionsView?.isMovieRecommendations
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTab.viewType === LibraryTab.Programs || currentTab.viewType === LibraryTab.Recordings || currentTab.viewType === LibraryTab.Schedule) {
|
||||
return (
|
||||
<ProgramsSectionView
|
||||
parentId={parentId}
|
||||
sectionType={
|
||||
currentTab.sectionsView?.programSections ?? []
|
||||
}
|
||||
isUpcomingRecordingsEnabled={currentTab.sectionsView?.isLiveTvUpcomingRecordings}
|
||||
/>
|
||||
<>
|
||||
<Box className='padded-top padded-left padded-right padded-bottom'>
|
||||
<LibraryViewMenu />
|
||||
</Box>
|
||||
|
||||
<ProgramsSectionView
|
||||
parentId={parentId}
|
||||
sectionType={
|
||||
currentTab.sectionsView?.programSections ?? []
|
||||
}
|
||||
isUpcomingRecordingsEnabled={currentTab.sectionsView?.isLiveTvUpcomingRecordings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTab.viewType === LibraryTab.Upcoming) {
|
||||
return <UpcomingView parentId={parentId} />;
|
||||
return (
|
||||
<>
|
||||
<Box className='padded-top padded-left padded-right padded-bottom'>
|
||||
<LibraryViewMenu />
|
||||
</Box>
|
||||
|
||||
<UpcomingView parentId={parentId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTab.viewType === LibraryTab.Genres) {
|
||||
return (
|
||||
<GenresView
|
||||
parentId={parentId}
|
||||
collectionType={currentTab.collectionType}
|
||||
itemType={currentTab.itemType || []}
|
||||
/>
|
||||
<>
|
||||
<Box className='padded-top padded-left padded-right padded-bottom'>
|
||||
<LibraryViewMenu />
|
||||
</Box>
|
||||
|
||||
<GenresView
|
||||
parentId={parentId}
|
||||
collectionType={currentTab.collectionType}
|
||||
itemType={currentTab.itemType || []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTab.viewType === LibraryTab.Guide) {
|
||||
return <GuideView />;
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
className='padded-top padded-left padded-right padded-bottom'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
<LibraryViewMenu />
|
||||
</Box>
|
||||
|
||||
<GuideView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -47,10 +47,15 @@ const Pagination: FC<PaginationProps> = ({
|
|||
}, [limit, setLibraryViewSettings, startIndex]);
|
||||
|
||||
return (
|
||||
<Box className='paging'>
|
||||
<Box
|
||||
className='paging'
|
||||
sx={{
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='listPaging'
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<span>
|
||||
{globalize.translate(
|
||||
|
|
|
@ -51,6 +51,12 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
|
|||
title={globalize.translate('HeaderPlayAll')}
|
||||
className='paper-icon-button-light btnPlay autoSize'
|
||||
onClick={play}
|
||||
sx={{
|
||||
order: {
|
||||
xs: 1,
|
||||
sm: 'unset'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
</IconButton>
|
||||
|
|
|
@ -34,6 +34,12 @@ const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
|
|||
title={globalize.translate('AddToPlayQueue')}
|
||||
className='paper-icon-button-light btnQueue autoSize'
|
||||
onClick={queue}
|
||||
sx={{
|
||||
order: {
|
||||
xs: 3,
|
||||
sm: 'unset'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<QueueIcon />
|
||||
</IconButton>
|
||||
|
|
|
@ -42,6 +42,12 @@ const ShuffleButton: FC<ShuffleButtonProps> = ({ item, items, viewType, hasFilte
|
|||
title={globalize.translate('Shuffle')}
|
||||
className='paper-icon-button-light btnShuffle autoSize'
|
||||
onClick={shuffle}
|
||||
sx={{
|
||||
order: {
|
||||
xs: 2,
|
||||
sm: 'unset'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</IconButton>
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
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 { debounce } from 'lodash-es';
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import TabRoutes from './tabRoutes';
|
||||
import useCurrentTab from 'hooks/useCurrentTab';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
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 { searchParams, setSearchParams, activeTab } = useCurrentTab();
|
||||
|
||||
// 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();
|
||||
|
||||
const tabIndex = event.currentTarget.dataset.tabIndex;
|
||||
|
||||
if (tabIndex) {
|
||||
searchParams.set('tab', tabIndex);
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}, [ searchParams, setSearchParams ]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{
|
||||
TabRoutes.map(route => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexShrink: {
|
||||
xs: 0,
|
||||
lg: 'unset'
|
||||
},
|
||||
order: {
|
||||
xs: 100,
|
||||
lg: 'unset'
|
||||
}
|
||||
}}
|
||||
variant={isBigScreen ? 'standard' : 'scrollable'}
|
||||
centered={isBigScreen}
|
||||
>
|
||||
{
|
||||
route.tabs.map(({ index, label }) => (
|
||||
<Tab
|
||||
key={`${route.path}-tab-${index}`}
|
||||
label={globalize.translate(label)}
|
||||
data-tab-index={`${index}`}
|
||||
onClick={onTabClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Tabs>
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTabs;
|
11
src/apps/experimental/constants/PseudoUserViews.ts
Normal file
11
src/apps/experimental/constants/PseudoUserViews.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
|
||||
/**
|
||||
* Views in the web app that we treat as UserViews.
|
||||
*/
|
||||
export const PseudoUserViews: Record<string, BaseItemDto> = {
|
||||
Favorites: {
|
||||
Id: 'favorites',
|
||||
Name: 'Favorites'
|
||||
}
|
||||
};
|
|
@ -46,7 +46,6 @@ const VideoPage: FC = () => {
|
|||
<AppToolbar
|
||||
isDrawerAvailable={false}
|
||||
isDrawerOpen={false}
|
||||
isFullscreen
|
||||
isUserMenuAvailable={false}
|
||||
buttons={
|
||||
<>
|
||||
|
|
|
@ -16,8 +16,8 @@ interface AppToolbarProps {
|
|||
buttons?: ReactNode
|
||||
isDrawerAvailable: boolean
|
||||
isDrawerOpen: boolean
|
||||
onDrawerButtonClick?: (event: React.MouseEvent<HTMLElement>) => void,
|
||||
isFullscreen?: boolean,
|
||||
onDrawerButtonClick?: (event: React.MouseEvent<HTMLElement>) => void
|
||||
isBackButtonAvailable?: boolean
|
||||
isUserMenuAvailable?: boolean
|
||||
}
|
||||
|
||||
|
@ -34,31 +34,21 @@ const AppToolbar: FC<PropsWithChildren<AppToolbarProps>> = ({
|
|||
isDrawerAvailable,
|
||||
isDrawerOpen,
|
||||
onDrawerButtonClick = () => { /* no-op */ },
|
||||
isFullscreen = false,
|
||||
isBackButtonAvailable = false,
|
||||
isUserMenuAvailable = true
|
||||
}) => {
|
||||
const { user } = useApi();
|
||||
const isUserLoggedIn = Boolean(user);
|
||||
|
||||
const isBackButtonAvailable = appRouter.canGoBack();
|
||||
|
||||
// Only use the left safe area padding when the drawer is not pinned or in a fullscreen view
|
||||
const useSafeAreaLeft = isDrawerAvailable || isFullscreen;
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
variant='dense'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
lg: 'nowrap'
|
||||
flexWrap: 'wrap',
|
||||
pl: {
|
||||
xs: 'max(16px, env(safe-area-inset-left))',
|
||||
sm: 'max(24px, env(safe-area-inset-left))'
|
||||
},
|
||||
...(useSafeAreaLeft && {
|
||||
pl: {
|
||||
xs: 'max(16px, env(safe-area-inset-left))',
|
||||
sm: 'max(24px, env(safe-area-inset-left))'
|
||||
}
|
||||
}),
|
||||
pr: {
|
||||
xs: 'max(16px, env(safe-area-inset-left))',
|
||||
sm: 'max(24px, env(safe-area-inset-left))'
|
||||
|
|
37
src/components/toolbar/ServerButton.tsx
Normal file
37
src/components/toolbar/ServerButton.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import Button from '@mui/material/Button/Button';
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useSystemInfo } from 'hooks/useSystemInfo';
|
||||
|
||||
const ServerButton: FC = () => {
|
||||
const {
|
||||
data: systemInfo,
|
||||
isPending
|
||||
} = useSystemInfo();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='text'
|
||||
size='large'
|
||||
color='inherit'
|
||||
startIcon={
|
||||
<img
|
||||
src='assets/img/icon-transparent.png'
|
||||
alt=''
|
||||
aria-hidden
|
||||
style={{
|
||||
maxHeight: '1.25em',
|
||||
maxWidth: '1.25em'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
component={Link}
|
||||
to='/'
|
||||
>
|
||||
{isPending ? '' : (systemInfo?.ServerName || 'Jellyfin')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerButton;
|
Loading…
Add table
Add a link
Reference in a new issue