1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge branch 'master' into Sorting-Only-Thumbnail-Fix-5584

This commit is contained in:
Marco 2023-10-05 22:53:18 +01:00 committed by GitHub
commit 0f8d29a573
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
193 changed files with 5197 additions and 1973 deletions

View file

@ -16,7 +16,7 @@ jobs:
- task: NodeTool@0 - task: NodeTool@0
displayName: 'Install Node' displayName: 'Install Node'
inputs: inputs:
versionSpec: '16.x' versionSpec: '20.x'
- task: Cache@2 - task: Cache@2
displayName: 'Cache node_modules' displayName: 'Cache node_modules'

View file

@ -0,0 +1,23 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
//https://github.com/microsoft/vscode-dev-containers/issues/559
"postCreateCommand": "source $NVM_DIR/nvm.sh && nvm install 20"
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

@ -66,6 +66,7 @@ module.exports = {
'no-unused-expressions': ['off'], 'no-unused-expressions': ['off'],
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }], '@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
'no-unused-private-class-members': ['error'], 'no-unused-private-class-members': ['error'],
'no-useless-rename': ['error'],
'no-useless-constructor': ['off'], 'no-useless-constructor': ['off'],
'@typescript-eslint/no-useless-constructor': ['error'], '@typescript-eslint/no-useless-constructor': ['error'],
'no-var': ['error'], 'no-var': ['error'],

View file

@ -18,7 +18,7 @@ jobs:
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
cache: npm cache: npm
@ -42,7 +42,7 @@ jobs:
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
cache: npm cache: npm
@ -63,7 +63,7 @@ jobs:
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
cache: npm cache: npm
@ -87,7 +87,7 @@ jobs:
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
cache: npm cache: npm

View file

@ -18,7 +18,7 @@ jobs:
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with: with:
node-version: 16 node-version: 20
check-latest: true check-latest: true
cache: npm cache: npm
@ -27,3 +27,6 @@ jobs:
- name: Run tsc - name: Run tsc
run: npm run build:check run: npm run build:check
- name: Run test suite
run: npm run test

View file

@ -67,6 +67,7 @@
- [sleepycatcoding](https://github.com/sleepycatcoding) - [sleepycatcoding](https://github.com/sleepycatcoding)
- [TheMelmacian](https://github.com/TheMelmacian) - [TheMelmacian](https://github.com/TheMelmacian)
- [v0idMrK](https://github.com/v0idMrK) - [v0idMrK](https://github.com/v0idMrK)
- [tehciolo](https://github.com/tehciolo)
# Emby Contributors # Emby Contributors

View file

@ -77,8 +77,9 @@ Jellyfin Web is the frontend used for most of the clients available for end user
. .
└── src └── src
├── apps ├── apps
│   ├── experimental # New experimental app layout │   ├── dashboard # Admin dashboard app layout and routes
│   └── stable # Classic (stable) app layout │   ├── experimental # New experimental app layout and routes
│   └── stable # Classic (stable) app layout and routes
├── assets # Static assets ├── assets # Static assets
├── components # Higher order visual components and React components ├── components # Higher order visual components and React components
├── controllers # Legacy page views and controllers 🧹 ├── controllers # Legacy page views and controllers 🧹
@ -87,7 +88,6 @@ Jellyfin Web is the frontend used for most of the clients available for end user
├── legacy # Polyfills for legacy browsers ├── legacy # Polyfills for legacy browsers
├── libraries # Third party libraries 🧹 ├── libraries # Third party libraries 🧹
├── plugins # Client plugins ├── plugins # Client plugins
├── routes # React routes/pages
├── scripts # Random assortment of visual components and utilities 🐉 ├── scripts # Random assortment of visual components and utilities 🐉
├── strings # Translation files ├── strings # Translation files
├── styles # Common app Sass stylesheets ├── styles # Common app Sass stylesheets

View file

@ -13,8 +13,8 @@ ENV IS_DOCKER=YES
RUN yum update -y \ RUN yum update -y \
&& yum install -y epel-release \ && yum install -y epel-release \
&& yum install -y rpmdevtools git autoconf automake glibc-devel gcc-c++ make \ && yum install -y rpmdevtools git autoconf automake glibc-devel gcc-c++ make \
&& curl -fsSL https://rpm.nodesource.com/setup_16.x | bash - \ && yum install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
&& yum install -y nodejs && yum install nodejs -y --setopt=nodesource-nodejs.module_hotfixes=1
# Link to build script # Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.centos /build.sh RUN ln -sf ${SOURCE_DIR}/deployment/build.centos /build.sh

View file

@ -12,11 +12,13 @@ ENV IS_DOCKER=YES
# Prepare Debian build environment # Prepare Debian build environment
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y debhelper mmv git curl \ && apt-get install -y debhelper mmv git curl gnupg ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \ && mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs && apt-get install -y nodejs
# Link to build script # Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.debian /build.sh RUN ln -sf ${SOURCE_DIR}/deployment/build.debian /build.sh

View file

@ -1,4 +1,4 @@
FROM node:lts-alpine FROM node:20-alpine
ARG SOURCE_DIR=/src ARG SOURCE_DIR=/src
ARG ARTIFACT_DIR=/jellyfin-web ARG ARTIFACT_DIR=/jellyfin-web

View file

@ -11,7 +11,8 @@ ENV IS_DOCKER=YES
# Prepare Fedora environment # Prepare Fedora environment
RUN dnf update -y \ RUN dnf update -y \
&& dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel make && yum install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
&& dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel make --setopt=nodesource-nodejs.module_hotfixes=1
# Link to build script # Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora /build.sh RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora /build.sh

View file

@ -11,8 +11,11 @@ ENV IS_DOCKER=YES
# Prepare Debian build environment # Prepare Debian build environment
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y mmv curl git \ && apt-get install -y mmv curl git gnupg ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \ && mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs && apt-get install -y nodejs
# Link to build script # Link to build script

View file

@ -7,7 +7,7 @@ RELEASE := $(shell set -x; sed -ne '/^Release:/s/.* *\(.*\)%{.*}.*/\1/p' $(DIR)
SRPM := jellyfin-web-$(subst -,~,$(VERSION))-$(RELEASE)$(shell rpm --eval %dist).src.rpm SRPM := jellyfin-web-$(subst -,~,$(VERSION))-$(RELEASE)$(shell rpm --eval %dist).src.rpm
TARBALL :=$(NAME)-$(subst -,~,$(VERSION)).tar.gz TARBALL :=$(NAME)-$(subst -,~,$(VERSION)).tar.gz
epel-7-x86_64_repos := https://rpm.nodesource.com/pub_16.x/el/\$$releasever/\$$basearch/ epel-7-x86_64_repos := https://rpm.nodesource.com/pub_20.x/nodistro/\$$basearch/
fed_ver := $(shell rpm -E %fedora) fed_ver := $(shell rpm -E %fedora)
# fallback when not running on Fedora # fallback when not running on Fedora

View file

@ -14,10 +14,10 @@ BuildArch: noarch
BuildRequires: nodejs BuildRequires: nodejs
%else %else
BuildRequires: git BuildRequires: git
# Nodejs 16 is required and npm >= 8 should bring in NodeJS 16 # Nodejs 20 is required and npm >= 10 should bring in NodeJS 20
# This requires the build environment to use the nodejs:16 module stream: # This requires the build environment to use the nodejs:20 module stream:
# dnf module {install|switch-to}:web nodejs:16 # dnf module {install|switch-to}:web nodejs:20
BuildRequires: npm >= 8 BuildRequires: npm >= 10
%endif %endif
%description %description

1678
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -58,6 +58,7 @@
"stylelint-scss": "5.0.0", "stylelint-scss": "5.0.0",
"ts-loader": "9.4.4", "ts-loader": "9.4.4",
"typescript": "5.0.4", "typescript": "5.0.4",
"vitest": "0.34.6",
"webpack": "5.88.1", "webpack": "5.88.1",
"webpack-bundle-analyzer": "4.9.1", "webpack-bundle-analyzer": "4.9.1",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
@ -145,13 +146,15 @@
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"escheck": "es-check", "escheck": "es-check",
"lint": "eslint \"./\"", "lint": "eslint \"./\"",
"test": "vitest --watch=false",
"test:watch": "vitest",
"stylelint": "npm run stylelint:css && npm run stylelint:scss", "stylelint": "npm run stylelint:css && npm run stylelint:scss",
"stylelint:css": "stylelint \"src/**/*.css\"", "stylelint:css": "stylelint \"src/**/*.css\"",
"stylelint:scss": "stylelint --config=\".stylelintrc.scss.json\" \"src/**/*.scss\"" "stylelint:scss": "stylelint --config=\".stylelintrc.scss.json\" \"src/**/*.scss\""
}, },
"engines": { "engines": {
"node": ">=16.13.1", "node": ">=20.0.0",
"npm": ">=8.1.2", "npm": ">=9.6.4",
"yarn": "YARN NO LONGER USED - use npm instead." "yarn": "YARN NO LONGER USED - use npm instead."
} }
} }

View file

@ -1,37 +1,62 @@
import loadable from '@loadable/component'; import loadable from '@loadable/component';
import { ThemeProvider } from '@mui/material/styles';
import { History } from '@remix-run/router'; import { History } from '@remix-run/router';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import { useLocation } from 'react-router-dom';
import StableApp from './apps/stable/App'; import { DASHBOARD_APP_PATHS } from 'apps/dashboard/App';
import { HistoryRouter } from './components/router/HistoryRouter'; import AppHeader from 'components/AppHeader';
import { ApiProvider } from './hooks/useApi'; import Backdrop from 'components/Backdrop';
import { WebConfigProvider } from './hooks/useWebConfig'; import { HistoryRouter } from 'components/router/HistoryRouter';
import { ApiProvider } from 'hooks/useApi';
import { WebConfigProvider } from 'hooks/useWebConfig';
import theme from 'themes/theme';
const DashboardApp = loadable(() => import('./apps/dashboard/App'));
const ExperimentalApp = loadable(() => import('./apps/experimental/App')); const ExperimentalApp = loadable(() => import('./apps/experimental/App'));
const StableApp = loadable(() => import('./apps/stable/App'));
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const RootApp = ({ history }: { history: History }) => { const RootAppLayout = () => {
const layoutMode = localStorage.getItem('layout'); const layoutMode = localStorage.getItem('layout');
const isExperimentalLayout = layoutMode === 'experimental';
const location = useLocation();
const isNewLayoutPath = Object.values(DASHBOARD_APP_PATHS)
.some(path => location.pathname.startsWith(`/${path}`));
return ( return (
<QueryClientProvider client={queryClient}> <>
<ApiProvider> <Backdrop />
<WebConfigProvider> <AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
<HistoryRouter history={history}>
{ {
layoutMode === 'experimental' ? isExperimentalLayout ?
<ExperimentalApp /> : <ExperimentalApp /> :
<StableApp /> <StableApp />
} }
</HistoryRouter>
</WebConfigProvider> <DashboardApp />
</ApiProvider> </>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
); );
}; };
const RootApp = ({ history }: { history: History }) => (
<QueryClientProvider client={queryClient}>
<ApiProvider>
<WebConfigProvider>
<ThemeProvider theme={theme}>
<HistoryRouter history={history}>
<RootAppLayout />
</HistoryRouter>
</ThemeProvider>
</WebConfigProvider>
</ApiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
export default RootApp; export default RootApp;

View file

@ -0,0 +1,66 @@
import loadable from '@loadable/component';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import ConnectionRequired from 'components/ConnectionRequired';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { AsyncPageProps, AsyncRoute, toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toRedirectRoute } from 'components/router/Redirect';
import ServerContentPage from 'components/ServerContentPage';
import AppLayout from './AppLayout';
import { REDIRECTS } from './routes/_redirects';
import { ASYNC_ADMIN_ROUTES } from './routes/_asyncRoutes';
import { LEGACY_ADMIN_ROUTES } from './routes/_legacyRoutes';
const DashboardAsyncPage = loadable(
(props: { page: string }) => import(/* webpackChunkName: "[request]" */ `./routes/${props.page}`),
{ cacheKey: (props: AsyncPageProps) => props.page }
);
const toDashboardAsyncPageRoute = (route: AsyncRoute) => (
toAsyncPageRoute({
...route,
element: DashboardAsyncPage
})
);
export const DASHBOARD_APP_PATHS = {
Dashboard: 'dashboard',
MetadataManager: 'metadata',
PluginConfig: 'configurationpage'
};
const DashboardApp = () => (
<Routes>
<Route element={<ConnectionRequired isAdminRequired />}>
<Route element={<AppLayout drawerlessPaths={[ DASHBOARD_APP_PATHS.MetadataManager ]} />}>
<Route path={DASHBOARD_APP_PATHS.Dashboard}>
{ASYNC_ADMIN_ROUTES.map(toDashboardAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* NOTE: The metadata editor might deserve a dedicated app in the future */}
{toViewManagerPageRoute({
path: DASHBOARD_APP_PATHS.MetadataManager,
pageProps: {
controller: 'edititemmetadata',
view: 'edititemmetadata.html'
}
})}
<Route path={DASHBOARD_APP_PATHS.PluginConfig} element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} />
</Route>
{/* Redirects for old paths */}
{REDIRECTS.map(toRedirectRoute)}
</Routes>
);
export default DashboardApp;

View file

@ -0,0 +1,108 @@
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody';
import AppToolbar from 'components/toolbar/AppToolbar';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import AppDrawer from './components/drawer/AppDrawer';
import './AppOverrides.scss';
interface AppLayoutProps {
drawerlessPaths: string[]
}
interface DashboardAppSettings {
isDrawerPinned: boolean
}
const DEFAULT_APP_SETTINGS: DashboardAppSettings = {
isDrawerPinned: false
};
const AppLayout: FC<AppLayoutProps> = ({
drawerlessPaths
}) => {
const [ appSettings, setAppSettings ] = useLocalStorage<DashboardAppSettings>('DashboardAppSettings', DEFAULT_APP_SETTINGS);
const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned);
const location = useLocation();
const theme = useTheme();
const { user } = useApi();
const isDrawerAvailable = !drawerlessPaths.some(path => location.pathname.startsWith(`/${path}`));
const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user);
useEffect(() => {
if (isDrawerActive !== appSettings.isDrawerPinned) {
setAppSettings({
...appSettings,
isDrawerPinned: isDrawerActive
});
}
}, [ appSettings, isDrawerActive, setAppSettings ]);
const onToggleDrawer = useCallback(() => {
setIsDrawerActive(!isDrawerActive);
}, [ isDrawerActive, setIsDrawerActive ]);
return (
<Box sx={{ display: 'flex' }}>
<ElevationScroll elevate={isDrawerOpen}>
<AppBar
position='fixed'
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
>
<AppToolbar
isDrawerAvailable={isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
marginLeft: 0,
...(isDrawerAvailable && {
marginLeft: {
sm: `-${DRAWER_WIDTH}px`
}
}),
...(isDrawerActive && {
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen
}),
marginLeft: 0
})
}}
>
<AppBody>
<Outlet />
</AppBody>
</Box>
</Box>
);
};
export default AppLayout;

View file

@ -0,0 +1,22 @@
// Default MUI breakpoints
// https://mui.com/material-ui/customization/breakpoints/#default-breakpoints
$mui-bp-sm: 600px;
$mui-bp-md: 900px;
$mui-bp-lg: 1200px;
$mui-bp-xl: 1536px;
// Fix dashboard pages layout to work with drawer
.dashboardDocument {
.mainAnimatedPage {
position: relative;
}
.skinBody {
position: unset !important;
}
// Fix the padding of dashboard pages
.content-primary.content-primary {
padding-top: 3.25rem !important;
}
}

View file

@ -0,0 +1,29 @@
import React, { FC } from 'react';
import ResponsiveDrawer, { ResponsiveDrawerProps } from 'components/ResponsiveDrawer';
import ServerDrawerSection from './sections/ServerDrawerSection';
import DevicesDrawerSection from './sections/DevicesDrawerSection';
import LiveTvDrawerSection from './sections/LiveTvDrawerSection';
import AdvancedDrawerSection from './sections/AdvancedDrawerSection';
import PluginDrawerSection from './sections/PluginDrawerSection';
const AppDrawer: FC<ResponsiveDrawerProps> = ({
open = false,
onClose,
onOpen
}) => (
<ResponsiveDrawer
open={open}
onClose={onClose}
onOpen={onOpen}
>
<ServerDrawerSection />
<DevicesDrawerSection />
<LiveTvDrawerSection />
<AdvancedDrawerSection />
<PluginDrawerSection />
</ResponsiveDrawer>
);
export default AppDrawer;

View file

@ -15,15 +15,14 @@ import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const PLUGIN_PATHS = [ const PLUGIN_PATHS = [
'/installedplugins.html', '/dashboard/plugins',
'/availableplugins.html', '/dashboard/plugins/catalog',
'/repositories.html', '/dashboard/plugins/repositories',
'/addplugin.html', '/dashboard/plugins/add',
'/configurationpage' '/configurationpage'
]; ];
@ -42,7 +41,7 @@ const AdvancedDrawerSection = () => {
} }
> >
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/networking.html'> <ListItemLink to='/dashboard/networking'>
<ListItemIcon> <ListItemIcon>
<Lan /> <Lan />
</ListItemIcon> </ListItemIcon>
@ -50,7 +49,7 @@ const AdvancedDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/apikeys.html'> <ListItemLink to='/dashboard/keys'>
<ListItemIcon> <ListItemIcon>
<VpnKey /> <VpnKey />
</ListItemIcon> </ListItemIcon>
@ -58,7 +57,7 @@ const AdvancedDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/log.html'> <ListItemLink to='/dashboard/logs'>
<ListItemIcon> <ListItemIcon>
<Article /> <Article />
</ListItemIcon> </ListItemIcon>
@ -66,7 +65,7 @@ const AdvancedDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/notificationsettings.html'> <ListItemLink to='/dashboard/notifications'>
<ListItemIcon> <ListItemIcon>
<EditNotifications /> <EditNotifications />
</ListItemIcon> </ListItemIcon>
@ -74,7 +73,7 @@ const AdvancedDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/installedplugins.html' selected={false}> <ListItemLink to='/dashboard/plugins' selected={false}>
<ListItemIcon> <ListItemIcon>
<Extension /> <Extension />
</ListItemIcon> </ListItemIcon>
@ -84,19 +83,19 @@ const AdvancedDrawerSection = () => {
</ListItem> </ListItem>
<Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit> <Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding> <List component='div' disablePadding>
<ListItemLink to='/installedplugins.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/plugins' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabMyPlugins')} /> <ListItemText inset primary={globalize.translate('TabMyPlugins')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/availableplugins.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/plugins/catalog' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabCatalog')} /> <ListItemText inset primary={globalize.translate('TabCatalog')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/repositories.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/plugins/repositories' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabRepositories')} /> <ListItemText inset primary={globalize.translate('TabRepositories')} />
</ListItemLink> </ListItemLink>
</List> </List>
</Collapse> </Collapse>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/scheduledtasks.html'> <ListItemLink to='/dashboard/tasks'>
<ListItemIcon> <ListItemIcon>
<Schedule /> <Schedule />
</ListItemIcon> </ListItemIcon>

View file

@ -8,13 +8,12 @@ import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const DLNA_PATHS = [ const DLNA_PATHS = [
'/dlnasettings.html', '/dashboard/dlna',
'/dlnaprofiles.html' '/dashboard/dlna/profiles'
]; ];
const DevicesDrawerSection = () => { const DevicesDrawerSection = () => {
@ -32,7 +31,7 @@ const DevicesDrawerSection = () => {
} }
> >
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/devices.html'> <ListItemLink to='/dashboard/devices'>
<ListItemIcon> <ListItemIcon>
<Devices /> <Devices />
</ListItemIcon> </ListItemIcon>
@ -48,7 +47,7 @@ const DevicesDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dlnasettings.html' selected={false}> <ListItemLink to='/dashboard/dlna' selected={false}>
<ListItemIcon> <ListItemIcon>
<Input /> <Input />
</ListItemIcon> </ListItemIcon>
@ -58,10 +57,10 @@ const DevicesDrawerSection = () => {
</ListItem> </ListItem>
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit> <Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding> <List component='div' disablePadding>
<ListItemLink to='/dlnasettings.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/dlna' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Settings')} /> <ListItemText inset primary={globalize.translate('Settings')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/dlnaprofiles.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/dlna/profiles' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabProfiles')} /> <ListItemText inset primary={globalize.translate('TabProfiles')} />
</ListItemLink> </ListItemLink>
</List> </List>

View file

@ -6,10 +6,9 @@ import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const LiveTvDrawerSection = () => { const LiveTvDrawerSection = () => {
return ( return (
<List <List
@ -21,7 +20,7 @@ const LiveTvDrawerSection = () => {
} }
> >
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/livetvstatus.html'> <ListItemLink to='/dashboard/livetv'>
<ListItemIcon> <ListItemIcon>
<LiveTv /> <LiveTv />
</ListItemIcon> </ListItemIcon>
@ -29,7 +28,7 @@ const LiveTvDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/livetvsettings.html'> <ListItemLink to='/dashboard/recordings'>
<ListItemIcon> <ListItemIcon>
<Dvr /> <Dvr />
</ListItemIcon> </ListItemIcon>

View file

@ -8,12 +8,11 @@ import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import ListItemLink from 'components/ListItemLink';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import Dashboard from 'utils/dashboard'; import Dashboard from 'utils/dashboard';
import ListItemLink from '../ListItemLink';
const PluginDrawerSection = () => { const PluginDrawerSection = () => {
const { api } = useApi(); const { api } = useApi();
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]); const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);

View file

@ -8,21 +8,20 @@ import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const LIBRARY_PATHS = [ const LIBRARY_PATHS = [
'/library.html', '/dashboard/libraries',
'/librarydisplay.html', '/dashboard/libraries/display',
'/metadataimages.html', '/dashboard/libraries/metadata',
'/metadatanfo.html' '/dashboard/libraries/nfo'
]; ];
const PLAYBACK_PATHS = [ const PLAYBACK_PATHS = [
'/encodingsettings.html', '/dashboard/playback/transcoding',
'/playbackconfiguration.html', '/dashboard/playback/resume',
'/streamingsettings.html' '/dashboard/playback/streaming'
]; ];
const ServerDrawerSection = () => { const ServerDrawerSection = () => {
@ -41,7 +40,7 @@ const ServerDrawerSection = () => {
} }
> >
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboard.html'> <ListItemLink to='/dashboard'>
<ListItemIcon> <ListItemIcon>
<Dashboard /> <Dashboard />
</ListItemIcon> </ListItemIcon>
@ -49,7 +48,7 @@ const ServerDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboardgeneral.html'> <ListItemLink to='/dashboard/settings'>
<ListItemIcon> <ListItemIcon>
<Settings /> <Settings />
</ListItemIcon> </ListItemIcon>
@ -57,7 +56,7 @@ const ServerDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/userprofiles.html'> <ListItemLink to='/dashboard/users'>
<ListItemIcon> <ListItemIcon>
<People /> <People />
</ListItemIcon> </ListItemIcon>
@ -65,7 +64,7 @@ const ServerDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/library.html' selected={false}> <ListItemLink to='/dashboard/libraries' selected={false}>
<ListItemIcon> <ListItemIcon>
<LibraryAdd /> <LibraryAdd />
</ListItemIcon> </ListItemIcon>
@ -75,22 +74,22 @@ const ServerDrawerSection = () => {
</ListItem> </ListItem>
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit> <Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding> <List component='div' disablePadding>
<ListItemLink to='/library.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/libraries' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('HeaderLibraries')} /> <ListItemText inset primary={globalize.translate('HeaderLibraries')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/librarydisplay.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/libraries/display' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Display')} /> <ListItemText inset primary={globalize.translate('Display')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/metadataimages.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Metadata')} /> <ListItemText inset primary={globalize.translate('Metadata')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/metadatanfo.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabNfoSettings')} /> <ListItemText inset primary={globalize.translate('TabNfoSettings')} />
</ListItemLink> </ListItemLink>
</List> </List>
</Collapse> </Collapse>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/encodingsettings.html' selected={false}> <ListItemLink to='/dashboard/playback/transcoding' selected={false}>
<ListItemIcon> <ListItemIcon>
<PlayCircle /> <PlayCircle />
</ListItemIcon> </ListItemIcon>
@ -100,13 +99,13 @@ const ServerDrawerSection = () => {
</ListItem> </ListItem>
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit> <Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding> <List component='div' disablePadding>
<ListItemLink to='/encodingsettings.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/playback/transcoding' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Transcoding')} /> <ListItemText inset primary={globalize.translate('Transcoding')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/playbackconfiguration.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/playback/resume' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('ButtonResume')} /> <ListItemText inset primary={globalize.translate('ButtonResume')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/streamingsettings.html' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabStreaming')} /> <ListItemText inset primary={globalize.translate('TabStreaming')} />
</ListItemLink> </ListItemLink>
</List> </List>

View file

@ -0,0 +1,12 @@
import type { AsyncRoute } from 'components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity' },
{ path: 'notifications' },
{ path: 'users' },
{ path: 'users/access' },
{ path: 'users/add' },
{ path: 'users/parentalcontrol' },
{ path: 'users/password' },
{ path: 'users/profile' }
];

View file

@ -1,176 +1,164 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute'; import type { LegacyRoute } from 'components/router/LegacyRoute';
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
{ {
path: 'dashboard.html', path: '/dashboard',
pageProps: { pageProps: {
controller: 'dashboard/dashboard', controller: 'dashboard/dashboard',
view: 'dashboard/dashboard.html' view: 'dashboard/dashboard.html'
} }
}, { }, {
path: 'dashboardgeneral.html', path: 'settings',
pageProps: { pageProps: {
controller: 'dashboard/general', controller: 'dashboard/general',
view: 'dashboard/general.html' view: 'dashboard/general.html'
} }
}, { }, {
path: 'networking.html', path: 'networking',
pageProps: { pageProps: {
controller: 'dashboard/networking', controller: 'dashboard/networking',
view: 'dashboard/networking.html' view: 'dashboard/networking.html'
} }
}, { }, {
path: 'devices.html', path: 'devices',
pageProps: { pageProps: {
controller: 'dashboard/devices/devices', controller: 'dashboard/devices/devices',
view: 'dashboard/devices/devices.html' view: 'dashboard/devices/devices.html'
} }
}, { }, {
path: 'device.html', path: 'devices/edit',
pageProps: { pageProps: {
controller: 'dashboard/devices/device', controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html' view: 'dashboard/devices/device.html'
} }
}, { }, {
path: 'quickConnect.html', path: 'dlna/profiles/edit',
pageProps: {
controller: 'dashboard/quickConnect',
view: 'dashboard/quickConnect.html'
}
}, {
path: 'dlnaprofile.html',
pageProps: { pageProps: {
controller: 'dashboard/dlna/profile', controller: 'dashboard/dlna/profile',
view: 'dashboard/dlna/profile.html' view: 'dashboard/dlna/profile.html'
} }
}, { }, {
path: 'dlnaprofiles.html', path: 'dlna/profiles',
pageProps: { pageProps: {
controller: 'dashboard/dlna/profiles', controller: 'dashboard/dlna/profiles',
view: 'dashboard/dlna/profiles.html' view: 'dashboard/dlna/profiles.html'
} }
}, { }, {
path: 'dlnasettings.html', path: 'dlna',
pageProps: { pageProps: {
controller: 'dashboard/dlna/settings', controller: 'dashboard/dlna/settings',
view: 'dashboard/dlna/settings.html' view: 'dashboard/dlna/settings.html'
} }
}, { }, {
path: 'addplugin.html', path: 'plugins/add',
pageProps: { pageProps: {
controller: 'dashboard/plugins/add/index', controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html' view: 'dashboard/plugins/add/index.html'
} }
}, { }, {
path: 'library.html', path: 'libraries',
pageProps: { pageProps: {
controller: 'dashboard/library', controller: 'dashboard/library',
view: 'dashboard/library.html' view: 'dashboard/library.html'
} }
}, { }, {
path: 'librarydisplay.html', path: 'libraries/display',
pageProps: { pageProps: {
controller: 'dashboard/librarydisplay', controller: 'dashboard/librarydisplay',
view: 'dashboard/librarydisplay.html' view: 'dashboard/librarydisplay.html'
} }
}, { }, {
path: 'edititemmetadata.html', path: 'playback/transcoding',
pageProps: {
controller: 'edititemmetadata',
view: 'edititemmetadata.html'
}
}, {
path: 'encodingsettings.html',
pageProps: { pageProps: {
controller: 'dashboard/encodingsettings', controller: 'dashboard/encodingsettings',
view: 'dashboard/encodingsettings.html' view: 'dashboard/encodingsettings.html'
} }
}, { }, {
path: 'log.html', path: 'logs',
pageProps: { pageProps: {
controller: 'dashboard/logs', controller: 'dashboard/logs',
view: 'dashboard/logs.html' view: 'dashboard/logs.html'
} }
}, { }, {
path: 'metadataimages.html', path: 'libraries/metadata',
pageProps: { pageProps: {
controller: 'dashboard/metadataImages', controller: 'dashboard/metadataImages',
view: 'dashboard/metadataimages.html' view: 'dashboard/metadataimages.html'
} }
}, { }, {
path: 'metadatanfo.html', path: 'libraries/nfo',
pageProps: { pageProps: {
controller: 'dashboard/metadatanfo', controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html' view: 'dashboard/metadatanfo.html'
} }
}, { }, {
path: 'playbackconfiguration.html', path: 'playback/resume',
pageProps: { pageProps: {
controller: 'dashboard/playback', controller: 'dashboard/playback',
view: 'dashboard/playback.html' view: 'dashboard/playback.html'
} }
}, { }, {
path: 'availableplugins.html', path: 'plugins/catalog',
pageProps: { pageProps: {
controller: 'dashboard/plugins/available/index', controller: 'dashboard/plugins/available/index',
view: 'dashboard/plugins/available/index.html' view: 'dashboard/plugins/available/index.html'
} }
}, { }, {
path: 'repositories.html', path: 'plugins/repositories',
pageProps: { pageProps: {
controller: 'dashboard/plugins/repositories/index', controller: 'dashboard/plugins/repositories/index',
view: 'dashboard/plugins/repositories/index.html' view: 'dashboard/plugins/repositories/index.html'
} }
}, { }, {
path: 'livetvguideprovider.html', path: 'livetv/guide',
pageProps: { pageProps: {
controller: 'livetvguideprovider', controller: 'livetvguideprovider',
view: 'livetvguideprovider.html' view: 'livetvguideprovider.html'
} }
}, { }, {
path: 'livetvsettings.html', path: 'recordings',
pageProps: { pageProps: {
controller: 'livetvsettings', controller: 'livetvsettings',
view: 'livetvsettings.html' view: 'livetvsettings.html'
} }
}, { }, {
path: 'livetvstatus.html', path: 'livetv',
pageProps: { pageProps: {
controller: 'livetvstatus', controller: 'livetvstatus',
view: 'livetvstatus.html' view: 'livetvstatus.html'
} }
}, { }, {
path: 'livetvtuner.html', path: 'livetv/tuner',
pageProps: { pageProps: {
controller: 'livetvtuner', controller: 'livetvtuner',
view: 'livetvtuner.html' view: 'livetvtuner.html'
} }
}, { }, {
path: 'installedplugins.html', path: 'plugins',
pageProps: { pageProps: {
controller: 'dashboard/plugins/installed/index', controller: 'dashboard/plugins/installed/index',
view: 'dashboard/plugins/installed/index.html' view: 'dashboard/plugins/installed/index.html'
} }
}, { }, {
path: 'scheduledtask.html', path: 'tasks/edit',
pageProps: { pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtask', controller: 'dashboard/scheduledtasks/scheduledtask',
view: 'dashboard/scheduledtasks/scheduledtask.html' view: 'dashboard/scheduledtasks/scheduledtask.html'
} }
}, { }, {
path: 'scheduledtasks.html', path: 'tasks',
pageProps: { pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtasks', controller: 'dashboard/scheduledtasks/scheduledtasks',
view: 'dashboard/scheduledtasks/scheduledtasks.html' view: 'dashboard/scheduledtasks/scheduledtasks.html'
} }
}, { }, {
path: 'apikeys.html', path: 'keys',
pageProps: { pageProps: {
controller: 'dashboard/apikeys', controller: 'dashboard/apikeys',
view: 'dashboard/apikeys.html' view: 'dashboard/apikeys.html'
} }
}, { }, {
path: 'streamingsettings.html', path: 'playback/streaming',
pageProps: { pageProps: {
view: 'dashboard/streaming.html', view: 'dashboard/streaming.html',
controller: 'dashboard/streaming' controller: 'dashboard/streaming'

View file

@ -0,0 +1,40 @@
import type { Redirect } from 'components/router/Redirect';
export const REDIRECTS: Redirect[] = [
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
{ from: 'apikeys.html', to: '/dashboard/keys' },
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
{ from: 'dashboard.html', to: '/dashboard' },
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
{ from: 'device.html', to: '/dashboard/devices/edit' },
{ from: 'devices.html', to: '/dashboard/devices' },
{ from: 'dlnaprofile.html', to: '/dashboard/dlna/profiles/edit' },
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna/profiles' },
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
{ from: 'edititemmetadata.html', to: '/metadata' },
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
{ from: 'installedplugins.html', to: '/dashboard/plugins' },
{ from: 'library.html', to: '/dashboard/libraries' },
{ from: 'librarydisplay.html', to: '/dashboard/libraries/display' },
{ from: 'livetvguideprovider.html', to: '/dashboard/livetv/guide' },
{ from: 'livetvsettings.html', to: '/dashboard/recordings' },
{ from: 'livetvstatus.html', to: '/dashboard/livetv' },
{ from: 'livetvtuner.html', to: '/dashboard/livetv/tuner' },
{ from: 'log.html', to: '/dashboard/logs' },
{ from: 'metadataimages.html', to: '/dashboard/libraries/metadata' },
{ from: 'metadatanfo.html', to: '/dashboard/libraries/nfo' },
{ from: 'networking.html', to: '/dashboard/networking' },
{ from: 'notificationsettings.html', to: '/dashboard/notifications' },
{ from: 'playbackconfiguration.html', to: '/dashboard/playback/resume' },
{ from: 'repositories.html', to: '/dashboard/plugins/repositories' },
{ from: 'scheduledtask.html', to: '/dashboard/tasks/edit' },
{ from: 'scheduledtasks.html', to: '/dashboard/tasks' },
{ from: 'serveractivity.html', to: '/dashboard/activity' },
{ from: 'streamingsettings.html', to: '/dashboard/playback/streaming' },
{ from: 'useredit.html', to: '/dashboard/users/profile' },
{ from: 'userlibraryaccess.html', to: '/dashboard/users/access' },
{ from: 'usernew.html', to: '/dashboard/users/add' },
{ from: 'userparentalcontrol.html', to: '/dashboard/users/parentalcontrol' },
{ from: 'userpassword.html', to: '/dashboard/users/password' },
{ from: 'userprofiles.html', to: '/dashboard/users' }
];

View file

@ -19,9 +19,9 @@ import { parseISO8601Date, toLocaleDateString, toLocaleTimeString } from 'script
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import { toBoolean } from 'utils/string'; import { toBoolean } from 'utils/string';
import LogLevelChip from '../../components/activityTable/LogLevelChip'; import LogLevelChip from '../components/activityTable/LogLevelChip';
import OverviewCell from '../../components/activityTable/OverviewCell'; import OverviewCell from '../components/activityTable/OverviewCell';
import GridActionsCellLink from '../../components/GridActionsCellLink'; import GridActionsCellLink from '../components/dataGrid/GridActionsCellLink';
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const VIEW_PARAM = 'useractivity'; const VIEW_PARAM = 'useractivity';
@ -68,7 +68,7 @@ const Activity = () => {
sx={{ padding: 0 }} sx={{ padding: 0 }}
title={users[row.UserId]?.Name ?? undefined} title={users[row.UserId]?.Name ?? undefined}
component={Link} component={Link}
to={`/useredit.html?userId=${row.UserId}`} to={`/dashboard/users/profile?userId=${row.UserId}`}
> >
<UserAvatar user={users[row.UserId]} /> <UserAvatar user={users[row.UserId]} />
</IconButton> </IconButton>

View file

@ -9,7 +9,7 @@ const PluginLink = () => (
__html: `<a __html: `<a
is='emby-linkbutton' is='emby-linkbutton'
class='button-link' class='button-link'
href='#/addplugin.html?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173' href='#/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
> >
${globalize.translate('GetThePlugin')} ${globalize.translate('GetThePlugin')}
</a>` </a>`

View file

@ -140,7 +140,7 @@ const UserNew: FunctionComponent = () => {
} }
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
Dashboard.navigate('useredit.html?userId=' + user.Id) Dashboard.navigate('/dashboard/users/profile?userId=' + user.Id)
.catch(err => { .catch(err => {
console.error('[usernew] failed to navigate to edit user page', err); console.error('[usernew] failed to navigate to edit user page', err);
}); });

View file

@ -85,21 +85,21 @@ const UserProfiles: FunctionComponent = () => {
callback: function (id: string) { callback: function (id: string) {
switch (id) { switch (id) {
case 'open': case 'open':
Dashboard.navigate('useredit.html?userId=' + userId) Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
.catch(err => { .catch(err => {
console.error('[userprofiles] failed to navigate to user edit page', err); console.error('[userprofiles] failed to navigate to user edit page', err);
}); });
break; break;
case 'access': case 'access':
Dashboard.navigate('userlibraryaccess.html?userId=' + userId) Dashboard.navigate('/dashboard/users/access?userId=' + userId)
.catch(err => { .catch(err => {
console.error('[userprofiles] failed to navigate to user library page', err); console.error('[userprofiles] failed to navigate to user library page', err);
}); });
break; break;
case 'parentalcontrol': case 'parentalcontrol':
Dashboard.navigate('userparentalcontrol.html?userId=' + userId) Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
.catch(err => { .catch(err => {
console.error('[userprofiles] failed to navigate to parental control page', err); console.error('[userprofiles] failed to navigate to parental control page', err);
}); });
@ -146,7 +146,7 @@ const UserProfiles: FunctionComponent = () => {
}); });
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() { (page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
Dashboard.navigate('usernew.html') Dashboard.navigate('/dashboard/users/add')
.catch(err => { .catch(err => {
console.error('[userprofiles] failed to navigate to new user page', err); console.error('[userprofiles] failed to navigate to new user page', err);
}); });

View file

@ -32,7 +32,7 @@ const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
); );
function onSaveComplete() { function onSaveComplete() {
Dashboard.navigate('userprofiles.html') Dashboard.navigate('/dashboard/users')
.catch(err => { .catch(err => {
console.error('[useredit] failed to navigate to user profile', err); console.error('[useredit] failed to navigate to user profile', err);
}); });

View file

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { DASHBOARD_APP_PATHS } from 'apps/dashboard/App';
import { REDIRECTS } from 'apps/stable/routes/_redirects';
import ConnectionRequired from 'components/ConnectionRequired'; import ConnectionRequired from 'components/ConnectionRequired';
import ServerContentPage from 'components/ServerContentPage';
import { toAsyncPageRoute } from 'components/router/AsyncRoute'; import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { toRedirectRoute } from 'components/router/Redirect';
import AppLayout from './AppLayout'; import AppLayout from './AppLayout';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes'; import { ASYNC_USER_ROUTES } from './routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes'; import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
const ExperimentalApp = () => { const ExperimentalApp = () => {
return ( return (
@ -20,26 +22,25 @@ const ExperimentalApp = () => {
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)} {LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
</Route> </Route>
{/* Admin routes */}
<Route element={<ConnectionRequired isAdminRequired />}>
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
<Route path='configurationpage' element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Public routes */} {/* Public routes */}
<Route element={<ConnectionRequired isUserRequired={false} />}> <Route element={<ConnectionRequired isUserRequired={false} />}>
<Route index element={<Navigate replace to='/home.html' />} /> <Route index element={<Navigate replace to='/home.html' />} />
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)} {LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
</Route> </Route>
{/* Redirects for old paths */}
<Route path='serveractivity.html' element={<Navigate replace to='/dashboard/activity' />} />
</Route> </Route>
{/* Redirects for old paths */}
{REDIRECTS.map(toRedirectRoute)}
{/* Ignore dashboard routes */}
{Object.entries(DASHBOARD_APP_PATHS).map(([ key, path ]) => (
<Route
key={key}
path={`/${path}/*`}
element={null}
/>
))}
</Routes> </Routes>
); );
}; };

View file

@ -1,18 +1,17 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import AppBar from '@mui/material/AppBar'; import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { ThemeProvider } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import AppHeader from 'components/AppHeader'; import AppBody from 'components/AppBody';
import Backdrop from 'components/Backdrop'; import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage'; import { useLocalStorage } from 'hooks/useLocalStorage';
import AppToolbar from './components/AppToolbar'; import AppToolbar from './components/AppToolbar';
import AppDrawer, { DRAWER_WIDTH, isDrawerPath } from './components/drawers/AppDrawer'; import AppDrawer, { isDrawerPath } from './components/drawers/AppDrawer';
import ElevationScroll from './components/ElevationScroll';
import theme from './theme';
import './AppOverrides.scss'; import './AppOverrides.scss';
@ -29,6 +28,7 @@ const AppLayout = () => {
const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned); const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned);
const { user } = useApi(); const { user } = useApi();
const location = useLocation(); const location = useLocation();
const theme = useTheme();
const isDrawerAvailable = isDrawerPath(location.pathname); const isDrawerAvailable = isDrawerPath(location.pathname);
const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user); const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user);
@ -47,67 +47,54 @@ const AppLayout = () => {
}, [ isDrawerActive, setIsDrawerActive ]); }, [ isDrawerActive, setIsDrawerActive ]);
return ( return (
<ThemeProvider theme={theme}> <Box sx={{ display: 'flex' }}>
<Backdrop /> <ElevationScroll elevate={isDrawerOpen}>
<AppBar
<div style={{ display: 'none' }}> position='fixed'
{/* sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
* TODO: These components are not used, but views interact with them directly so the need to be
* present in the dom. We add them in a hidden element to prevent errors.
*/}
<AppHeader />
</div>
<Box sx={{ display: 'flex' }}>
<ElevationScroll elevate={isDrawerOpen}>
<AppBar
position='fixed'
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
>
<AppToolbar
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
marginLeft: 0,
...(isDrawerAvailable && {
marginLeft: {
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' /> <AppToolbar
<div className='skinBody'> isDrawerOpen={isDrawerOpen}
<Outlet /> onDrawerButtonClick={onToggleDrawer}
</div> />
</Box> </AppBar>
</ElevationScroll>
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
marginLeft: 0,
...(isDrawerAvailable && {
marginLeft: {
sm: `-${DRAWER_WIDTH}px`
}
}),
...(isDrawerActive && {
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen
}),
marginLeft: 0
})
}}
>
<AppBody>
<Outlet />
</AppBody>
</Box> </Box>
</ThemeProvider> </Box>
); );
}; };

View file

@ -10,11 +10,6 @@ $mui-bp-xl: 1536px;
position: relative; position: relative;
} }
// Fix dashboard pages layout to work with drawer
.dashboardDocument .skinBody {
position: unset;
}
// Hide some items from the user "settings" page that are in the drawer // Hide some items from the user "settings" page that are in the drawer
#myPreferencesMenuPage { #myPreferencesMenuPage {
.lnkQuickConnectPreferences, .lnkQuickConnectPreferences,
@ -26,8 +21,7 @@ $mui-bp-xl: 1536px;
// Fix the padding of some pages // Fix the padding of some pages
.homePage.libraryPage, // Home page .homePage.libraryPage, // Home page
.libraryPage:not(.withTabs), // Tabless library pages .libraryPage:not(.withTabs) { // Tabless library pages
.content-primary.content-primary { // Dashboard pages
padding-top: 3.25rem !important; padding-top: 3.25rem !important;
} }

View file

@ -1,22 +1,14 @@
import ArrowBack from '@mui/icons-material/ArrowBack';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import appIcon from 'assets/img/icon-transparent.png'; import AppToolbar from 'components/toolbar/AppToolbar';
import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import AppTabs from '../tabs/AppTabs'; import AppTabs from '../tabs/AppTabs';
import { isDrawerPath } from '../drawers/AppDrawer'; import { isDrawerPath } from '../drawers/AppDrawer';
import UserMenuButton from './UserMenuButton';
import RemotePlayButton from './RemotePlayButton'; import RemotePlayButton from './RemotePlayButton';
import SyncPlayButton from './SyncPlayButton'; import SyncPlayButton from './SyncPlayButton';
@ -25,120 +17,40 @@ interface AppToolbarProps {
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
} }
const onBackButtonClick = () => { const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
appRouter.back()
.catch(err => {
console.error('[AppToolbar] error calling appRouter.back', err);
});
};
const AppToolbar: FC<AppToolbarProps> = ({
isDrawerOpen, isDrawerOpen,
onDrawerButtonClick onDrawerButtonClick
}) => { }) => {
const { user } = useApi();
const isUserLoggedIn = Boolean(user);
const location = useLocation(); const location = useLocation();
const isDrawerAvailable = isDrawerPath(location.pathname); const isDrawerAvailable = isDrawerPath(location.pathname);
const isBackButtonAvailable = appRouter.canGoBack();
return ( return (
<Toolbar <AppToolbar
variant='dense' buttons={
sx={{
flexWrap: {
xs: 'wrap',
lg: 'nowrap'
}
}}
>
{isUserLoggedIn && isDrawerAvailable && (
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
<IconButton
size='large'
edge='start'
color='inherit'
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
onClick={onDrawerButtonClick}
>
<MenuIcon />
</IconButton>
</Tooltip>
)}
{isBackButtonAvailable && (
<Tooltip title={globalize.translate('ButtonBack')}>
<IconButton
size='large'
// Set the edge if the drawer button is not shown
edge={!(isUserLoggedIn && isDrawerAvailable) ? 'start' : undefined}
color='inherit'
aria-label={globalize.translate('ButtonBack')}
onClick={onBackButtonClick}
>
<ArrowBack />
</IconButton>
</Tooltip>
)}
<Box
component={Link}
to='/'
color='inherit'
aria-label={globalize.translate('Home')}
sx={{
ml: 2,
display: 'inline-flex',
textDecoration: 'none'
}}
>
<Box
component='img'
src={appIcon}
sx={{
height: '2rem',
marginInlineEnd: 1
}}
/>
<Typography
variant='h6'
noWrap
component='div'
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
>
Jellyfin
</Typography>
</Box>
<AppTabs isDrawerOpen={isDrawerOpen} />
{isUserLoggedIn && (
<> <>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}> <SyncPlayButton />
<SyncPlayButton /> <RemotePlayButton />
<RemotePlayButton />
<Tooltip title={globalize.translate('Search')}> <Tooltip title={globalize.translate('Search')}>
<IconButton <IconButton
size='large' size='large'
aria-label={globalize.translate('Search')} aria-label={globalize.translate('Search')}
color='inherit' color='inherit'
component={Link} component={Link}
to='/search.html' to='/search.html'
> >
<SearchIcon /> <SearchIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box>
<Box sx={{ flexGrow: 0 }}>
<UserMenuButton />
</Box>
</> </>
)} }
</Toolbar> isDrawerAvailable={isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onDrawerButtonClick}
>
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
); );
}; };
export default AppToolbar; export default ExperimentalAppToolbar;

View file

@ -19,6 +19,7 @@ import React, { FC, useCallback, useEffect, useState } from 'react';
import { pluginManager } from 'components/pluginManager'; import { pluginManager } from 'components/pluginManager';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import { useSyncPlayGroups } from 'hooks/useSyncPlayGroups';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import { PluginType } from 'types/plugin'; import { PluginType } from 'types/plugin';
import Events from 'utils/events'; import Events from 'utils/events';
@ -47,7 +48,6 @@ const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
}) => { }) => {
const [ syncPlay, setSyncPlay ] = useState<SyncPlayInstance>(); const [ syncPlay, setSyncPlay ] = useState<SyncPlayInstance>();
const { __legacyApiClient__, api, user } = useApi(); const { __legacyApiClient__, api, user } = useApi();
const [ groups, setGroups ] = useState<GroupInfoDto[]>([]);
const [ currentGroup, setCurrentGroup ] = useState<GroupInfoDto>(); const [ currentGroup, setCurrentGroup ] = useState<GroupInfoDto>();
const isSyncPlayEnabled = Boolean(currentGroup); const isSyncPlayEnabled = Boolean(currentGroup);
@ -55,18 +55,7 @@ const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
setSyncPlay(pluginManager.firstOfType(PluginType.SyncPlay)?.instance); setSyncPlay(pluginManager.firstOfType(PluginType.SyncPlay)?.instance);
}, []); }, []);
useEffect(() => { const { data: groups } = useSyncPlayGroups();
const fetchGroups = async () => {
if (api) {
setGroups((await getSyncPlayApi(api).syncPlayGetGroups()).data);
}
};
fetchGroups()
.catch(err => {
console.error('[SyncPlayMenu] unable to fetch SyncPlay groups', err);
});
}, [ api ]);
const onGroupAddClick = useCallback(() => { const onGroupAddClick = useCallback(() => {
if (api && user) { if (api && user) {
@ -224,7 +213,7 @@ const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
/> />
</MenuItem> </MenuItem>
); );
} else if (groups.length === 0 && user?.Policy?.SyncPlayAccess !== SyncPlayUserAccessType.CreateAndJoinGroups) { } else if (!groups?.length && user?.Policy?.SyncPlayAccess !== SyncPlayUserAccessType.CreateAndJoinGroups) {
menuItems.push( menuItems.push(
<MenuItem key='sync-play-unavailable' disabled> <MenuItem key='sync-play-unavailable' disabled>
<ListItemIcon> <ListItemIcon>
@ -234,7 +223,7 @@ const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
</MenuItem> </MenuItem>
); );
} else { } else {
if (groups.length > 0) { if (groups && groups.length > 0) {
groups.forEach(group => { groups.forEach(group => {
menuItems.push( menuItems.push(
<MenuItem <MenuItem

View file

@ -1,21 +1,15 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { Route, Routes } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from '../../routes/asyncRoutes'; import ResponsiveDrawer, { ResponsiveDrawerProps } from 'components/ResponsiveDrawer';
import { LEGACY_ADMIN_ROUTES, LEGACY_USER_ROUTES } from '../../routes/legacyRoutes';
import { ASYNC_USER_ROUTES } from '../../routes/asyncRoutes';
import { LEGACY_USER_ROUTES } from '../../routes/legacyRoutes';
import { isTabPath } from '../tabs/tabRoutes';
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 MainDrawerContent from './MainDrawerContent';
import ResponsiveDrawer, { ResponsiveDrawerProps } from './ResponsiveDrawer';
export const DRAWER_WIDTH = 240;
const DRAWERLESS_ROUTES = [ const DRAWERLESS_ROUTES = [
'edititemmetadata.html', // metadata manager
'video' // video player 'video' // video player
]; ];
@ -24,63 +18,29 @@ const MAIN_DRAWER_ROUTES = [
...LEGACY_USER_ROUTES ...LEGACY_USER_ROUTES
].filter(route => !DRAWERLESS_ROUTES.includes(route.path)); ].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. */ /** Utility function to check if a path has a drawer. */
export const isDrawerPath = (path: string) => ( export const isDrawerPath = (path: string) => (
MAIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path) 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> = ({ const AppDrawer: FC<ResponsiveDrawerProps> = ({
open = false, open = false,
onClose, onClose,
onOpen onOpen
}) => ( }) => {
<Routes> const location = useLocation();
{ const hasSecondaryToolBar = isTabPath(location.pathname);
MAIN_DRAWER_ROUTES.map(route => (
<Route return (
key={route.path} <ResponsiveDrawer
path={route.path} hasSecondaryToolBar={hasSecondaryToolBar}
element={ open={open}
<ResponsiveDrawer onClose={onClose}
open={open} onOpen={onOpen}
onClose={onClose} >
onOpen={onOpen} <MainDrawerContent />
> </ResponsiveDrawer>
<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>
}
/>
))
}
</Routes>
);
export default AppDrawer; export default AppDrawer;

View file

@ -17,12 +17,12 @@ import ListSubheader from '@mui/material/ListSubheader';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import { useWebConfig } from 'hooks/useWebConfig'; import { useWebConfig } from 'hooks/useWebConfig';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import { appRouter } from 'components/router/appRouter';
import ListItemLink from './ListItemLink';
import LibraryIcon from '../LibraryIcon'; import LibraryIcon from '../LibraryIcon';
const MainDrawerContent = () => { const MainDrawerContent = () => {
@ -150,7 +150,7 @@ const MainDrawerContent = () => {
} }
> >
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboard.html'> <ListItemLink to='/dashboard'>
<ListItemIcon> <ListItemIcon>
<Dashboard /> <Dashboard />
</ListItemIcon> </ListItemIcon>
@ -158,7 +158,7 @@ const MainDrawerContent = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/edititemmetadata.html'> <ListItemLink to='/metadata'>
<ListItemIcon> <ListItemIcon>
<Edit /> <Edit />
</ListItemIcon> </ListItemIcon>

View file

@ -0,0 +1,85 @@
import React, { useCallback } from 'react';
import classNames from 'classnames';
import Box from '@mui/material/Box';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { LibraryViewSettings } from 'types/library';
import 'components/alphaPicker/style.scss';
interface AlphabetPickerProps {
className?: string;
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<
React.SetStateAction<LibraryViewSettings>
>;
}
const AlphabetPicker: React.FC<AlphabetPickerProps> = ({
className,
libraryViewSettings,
setLibraryViewSettings
}) => {
const handleValue = useCallback(
(
event: React.MouseEvent<HTMLElement>,
newValue: string | null | undefined
) => {
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: 0,
Alphabet: newValue
}));
},
[setLibraryViewSettings]
);
const containerClassName = classNames(
'alphaPicker',
className,
'alphaPicker-fixed-right'
);
const btnClassName = classNames(
'paper-icon-button-light',
'alphaPickerButton'
);
const letters = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
return (
<Box
className={containerClassName}
sx={{
position: 'fixed',
bottom: '1.5em',
fontSize: {
xs: '80%',
lg: '88%'
}
}}
>
<ToggleButtonGroup
orientation='vertical'
value={libraryViewSettings.Alphabet}
exclusive
color='primary'
size='small'
onChange={handleValue}
>
{letters.map((l) => (
<ToggleButton
key={l}
value={l}
className={btnClassName}
>
{l}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
);
};
export default AlphabetPicker;

View file

@ -0,0 +1,34 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import globalize from 'scripts/globalize';
const NewCollectionButton: FC = () => {
const showCollectionEditor = useCallback(() => {
import('components/collectionEditor/collectionEditor').then(
({ default: CollectionEditor }) => {
const serverId = window.ApiClient.serverId();
const collectionEditor = new CollectionEditor();
collectionEditor.show({
items: [],
serverId: serverId
}).catch(() => {
// closed collection editor
});
}).catch(err => {
console.error('[NewCollection] failed to load collection editor', err);
});
}, []);
return (
<IconButton
title={globalize.translate('Add')}
className='paper-icon-button-light btnNewCollection autoSize'
onClick={showCollectionEditor}
>
<AddIcon />
</IconButton>
);
};
export default NewCollectionButton;

View file

@ -0,0 +1,91 @@
import React, { FC, useCallback } from 'react';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import Box from '@mui/material/Box';
import ButtonGroup from '@mui/material/ButtonGroup';
import IconButton from '@mui/material/IconButton';
import globalize from 'scripts/globalize';
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryViewSettings } from 'types/library';
interface PaginationProps {
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<React.SetStateAction<LibraryViewSettings>>;
totalRecordCount: number;
}
const Pagination: FC<PaginationProps> = ({
libraryViewSettings,
setLibraryViewSettings,
totalRecordCount
}) => {
const limit = userSettings.libraryPageSize(undefined);
const startIndex = libraryViewSettings.StartIndex || 0;
const recordsStart = totalRecordCount ? startIndex + 1 : 0;
const recordsEnd = limit ?
Math.min(startIndex + limit, totalRecordCount) :
totalRecordCount;
const showControls = limit > 0 && limit < totalRecordCount;
const onNextPageClick = useCallback(() => {
if (limit > 0) {
const newIndex = startIndex + limit;
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}
}, [limit, setLibraryViewSettings, startIndex]);
const onPreviousPageClick = useCallback(() => {
if (limit > 0) {
const newIndex = Math.max(0, startIndex - limit);
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}
}, [limit, setLibraryViewSettings, startIndex]);
return (
<Box className='paging'>
<Box
className='listPaging'
style={{ display: 'flex', alignItems: 'center' }}
>
<span>
{globalize.translate(
'ListPaging',
recordsStart,
recordsEnd,
totalRecordCount
)}
</span>
{showControls && (
<ButtonGroup>
<IconButton
title={globalize.translate('Previous')}
className='paper-icon-button-light btnPreviousPage autoSize'
disabled={startIndex == 0}
onClick={onPreviousPageClick}
>
<ArrowBackIcon />
</IconButton>
<IconButton
title={globalize.translate('Next')}
className='paper-icon-button-light btnNextPage autoSize'
disabled={startIndex + limit >= totalRecordCount }
onClick={onNextPageClick}
>
<ArrowForwardIcon />
</IconButton>
</ButtonGroup>
)}
</Box>
</Box>
);
};
export default Pagination;

View file

@ -0,0 +1,57 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
import { getFiltersQuery } from 'utils/items';
import { LibraryViewSettings } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
interface PlayAllButtonProps {
item: BaseItemDto | undefined;
items: BaseItemDto[];
viewType: LibraryTab;
hasFilters: boolean;
libraryViewSettings: LibraryViewSettings
}
const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilters, libraryViewSettings }) => {
const play = useCallback(() => {
if (item && !hasFilters) {
playbackManager.play({
items: [item],
autoplay: true,
queryOptions: {
SortBy: [libraryViewSettings.SortBy],
SortOrder: [libraryViewSettings.SortOrder]
}
});
} else {
playbackManager.play({
items: items,
autoplay: true,
queryOptions: {
ParentId: item?.Id ?? undefined,
...getFiltersQuery(viewType, libraryViewSettings),
SortBy: [libraryViewSettings.SortBy],
SortOrder: [libraryViewSettings.SortOrder]
}
});
}
}, [hasFilters, item, items, libraryViewSettings, viewType]);
return (
<IconButton
title={globalize.translate('HeaderPlayAll')}
className='paper-icon-button-light btnPlay autoSize'
onClick={play}
>
<PlayArrowIcon />
</IconButton>
);
};
export default PlayAllButton;

View file

@ -0,0 +1,39 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import QueueIcon from '@mui/icons-material/Queue';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
interface QueueButtonProps {
item: BaseItemDto | undefined
items: BaseItemDto[];
hasFilters: boolean;
}
const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
const queue = useCallback(() => {
if (item && !hasFilters) {
playbackManager.queue({
items: [item]
});
} else {
playbackManager.queue({
items: items
});
}
}, [hasFilters, item, items]);
return (
<IconButton
title={globalize.translate('AddToPlayQueue')}
className='paper-icon-button-light btnQueue autoSize'
onClick={queue}
>
<QueueIcon />
</IconButton>
);
};
export default QueueButton;

View file

@ -0,0 +1,49 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import ShuffleIcon from '@mui/icons-material/Shuffle';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
import { getFiltersQuery } from 'utils/items';
import { LibraryViewSettings } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
interface ShuffleButtonProps {
item: BaseItemDto | undefined;
items: BaseItemDto[];
viewType: LibraryTab
hasFilters: boolean;
libraryViewSettings: LibraryViewSettings
}
const ShuffleButton: FC<ShuffleButtonProps> = ({ item, items, viewType, hasFilters, libraryViewSettings }) => {
const shuffle = useCallback(() => {
if (item && !hasFilters) {
playbackManager.shuffle(item);
} else {
playbackManager.play({
items: items,
autoplay: true,
queryOptions: {
ParentId: item?.Id ?? undefined,
...getFiltersQuery(viewType, libraryViewSettings),
SortBy: [ItemSortBy.Random]
}
});
}
}, [hasFilters, item, items, libraryViewSettings, viewType]);
return (
<IconButton
title={globalize.translate('Shuffle')}
className='paper-icon-button-light btnShuffle autoSize'
onClick={shuffle}
>
<ShuffleIcon />
</IconButton>
);
};
export default ShuffleButton;

View file

@ -98,7 +98,7 @@ const SortButton: FC<SortButtonProps> = ({
title={globalize.translate('Sort')} title={globalize.translate('Sort')}
sx={{ ml: 2 }} sx={{ ml: 2 }}
aria-describedby={id} aria-describedby={id}
className='paper-icon-button-light btnShuffle autoSize' className='paper-icon-button-light btnSort autoSize'
onClick={handleClick} onClick={handleClick}
> >
<SortByAlphaIcon /> <SortByAlphaIcon />

View file

@ -100,7 +100,7 @@ const ViewSettingsButton: FC<ViewSettingsButtonProps> = ({
title={globalize.translate('ButtonSelectView')} title={globalize.translate('ButtonSelectView')}
sx={{ ml: 2 }} sx={{ ml: 2 }}
aria-describedby={id} aria-describedby={id}
className='paper-icon-button-light btnShuffle autoSize' className='paper-icon-button-light btnSelectView autoSize'
onClick={handleClick} onClick={handleClick}
> >
<ViewComfyIcon /> <ViewComfyIcon />

View file

@ -1,12 +0,0 @@
import { AsyncRoute, AsyncRouteType } from 'components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'dashboard/activity', page: 'dashboard/activity', type: AsyncRouteType.Experimental },
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },
{ path: 'useredit.html', page: 'user/useredit' },
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
{ path: 'userpassword.html', page: 'user/userpassword' }
];

View file

@ -1,2 +1 @@
export * from './admin';
export * from './user'; export * from './user';

View file

@ -1,6 +1,7 @@
import { AsyncRoute, AsyncRouteType } from '../../../../components/router/AsyncRoute'; import { AsyncRoute, AsyncRouteType } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [ export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'search.html', page: 'search' }, { path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' }, { path: 'userprofile.html', page: 'user/userprofile' },
{ path: 'home.html', page: 'home', type: AsyncRouteType.Experimental }, { path: 'home.html', page: 'home', type: AsyncRouteType.Experimental },

View file

@ -1,3 +1,2 @@
export * from './admin';
export * from './public'; export * from './public';
export * from './user'; export * from './user';

View file

@ -49,12 +49,6 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
controller: 'user/home/index', controller: 'user/home/index',
view: 'user/home/index.html' view: 'user/home/index.html'
} }
}, {
path: 'mypreferencesquickconnect.html',
pageProps: {
controller: 'user/quickConnect/index',
view: 'user/quickConnect/index.html'
}
}, { }, {
path: 'mypreferencesplayback.html', path: 'mypreferencesplayback.html',
pageProps: { pageProps: {

View file

@ -1,26 +1,21 @@
import React from 'react'; import React from 'react';
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import AppHeader from 'components/AppHeader'; import { DASHBOARD_APP_PATHS } from 'apps/dashboard/App';
import Backdrop from 'components/Backdrop'; import AppBody from 'components/AppBody';
import ServerContentPage from 'components/ServerContentPage';
import ConnectionRequired from 'components/ConnectionRequired'; import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute'; import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { toRedirectRoute } from 'components/router/Redirect';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes'; import { ASYNC_USER_ROUTES } from './routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes'; import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
import { REDIRECTS } from './routes/_redirects';
const Layout = () => ( const Layout = () => (
<> <AppBody>
<Backdrop /> <Outlet />
<AppHeader /> </AppBody>
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
<Outlet />
</div>
</>
); );
const StableApp = () => ( const StableApp = () => (
@ -32,16 +27,6 @@ const StableApp = () => (
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)} {LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
</Route> </Route>
{/* Admin routes */}
<Route path='/' element={<ConnectionRequired isAdminRequired />}>
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
<Route path='configurationpage' element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Public routes */} {/* Public routes */}
<Route path='/' element={<ConnectionRequired isUserRequired={false} />}> <Route path='/' element={<ConnectionRequired isUserRequired={false} />}>
<Route index element={<Navigate replace to='/home.html' />} /> <Route index element={<Navigate replace to='/home.html' />} />
@ -51,10 +36,19 @@ const StableApp = () => (
{/* Suppress warnings for unhandled routes */} {/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} /> <Route path='*' element={null} />
{/* Redirects for old paths */}
<Route path='/serveractivity.html' element={<Navigate replace to='/dashboard/activity' />} />
</Route> </Route>
{/* Redirects for old paths */}
{REDIRECTS.map(toRedirectRoute)}
{/* Ignore dashboard routes */}
{Object.entries(DASHBOARD_APP_PATHS).map(([ key, path ]) => (
<Route
key={key}
path={`/${path}/*`}
element={null}
/>
))}
</Routes> </Routes>
); );

View file

@ -0,0 +1,5 @@
import type { Redirect } from 'components/router/Redirect';
export const REDIRECTS: Redirect[] = [
{ from: 'mypreferencesquickconnect.html', to: '/quickconnect' }
];

View file

@ -1,11 +0,0 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },
{ path: 'useredit.html', page: 'user/useredit' },
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
{ path: 'userpassword.html', page: 'user/userpassword' }
];

View file

@ -1,2 +1 @@
export * from './admin';
export * from './user'; export * from './user';

View file

@ -1,6 +1,7 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute'; import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [ export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'search.html', page: 'search' }, { path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' } { path: 'userprofile.html', page: 'user/userprofile' }
]; ];

View file

@ -1,185 +0,0 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
{
path: 'dashboard.html',
pageProps: {
controller: 'dashboard/dashboard',
view: 'dashboard/dashboard.html'
}
}, {
path: 'dashboardgeneral.html',
pageProps: {
controller: 'dashboard/general',
view: 'dashboard/general.html'
}
}, {
path: 'networking.html',
pageProps: {
controller: 'dashboard/networking',
view: 'dashboard/networking.html'
}
}, {
path: 'devices.html',
pageProps: {
controller: 'dashboard/devices/devices',
view: 'dashboard/devices/devices.html'
}
}, {
path: 'device.html',
pageProps: {
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
}
}, {
path: 'quickConnect.html',
pageProps: {
controller: 'dashboard/quickConnect',
view: 'dashboard/quickConnect.html'
}
}, {
path: 'dlnaprofile.html',
pageProps: {
controller: 'dashboard/dlna/profile',
view: 'dashboard/dlna/profile.html'
}
}, {
path: 'dlnaprofiles.html',
pageProps: {
controller: 'dashboard/dlna/profiles',
view: 'dashboard/dlna/profiles.html'
}
}, {
path: 'dlnasettings.html',
pageProps: {
controller: 'dashboard/dlna/settings',
view: 'dashboard/dlna/settings.html'
}
}, {
path: 'addplugin.html',
pageProps: {
controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html'
}
}, {
path: 'library.html',
pageProps: {
controller: 'dashboard/library',
view: 'dashboard/library.html'
}
}, {
path: 'librarydisplay.html',
pageProps: {
controller: 'dashboard/librarydisplay',
view: 'dashboard/librarydisplay.html'
}
}, {
path: 'edititemmetadata.html',
pageProps: {
controller: 'edititemmetadata',
view: 'edititemmetadata.html'
}
}, {
path: 'encodingsettings.html',
pageProps: {
controller: 'dashboard/encodingsettings',
view: 'dashboard/encodingsettings.html'
}
}, {
path: 'log.html',
pageProps: {
controller: 'dashboard/logs',
view: 'dashboard/logs.html'
}
}, {
path: 'metadataimages.html',
pageProps: {
controller: 'dashboard/metadataImages',
view: 'dashboard/metadataimages.html'
}
}, {
path: 'metadatanfo.html',
pageProps: {
controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html'
}
}, {
path: 'playbackconfiguration.html',
pageProps: {
controller: 'dashboard/playback',
view: 'dashboard/playback.html'
}
}, {
path: 'availableplugins.html',
pageProps: {
controller: 'dashboard/plugins/available/index',
view: 'dashboard/plugins/available/index.html'
}
}, {
path: 'repositories.html',
pageProps: {
controller: 'dashboard/plugins/repositories/index',
view: 'dashboard/plugins/repositories/index.html'
}
}, {
path: 'livetvguideprovider.html',
pageProps: {
controller: 'livetvguideprovider',
view: 'livetvguideprovider.html'
}
}, {
path: 'livetvsettings.html',
pageProps: {
controller: 'livetvsettings',
view: 'livetvsettings.html'
}
}, {
path: 'livetvstatus.html',
pageProps: {
controller: 'livetvstatus',
view: 'livetvstatus.html'
}
}, {
path: 'livetvtuner.html',
pageProps: {
controller: 'livetvtuner',
view: 'livetvtuner.html'
}
}, {
path: 'installedplugins.html',
pageProps: {
controller: 'dashboard/plugins/installed/index',
view: 'dashboard/plugins/installed/index.html'
}
}, {
path: 'scheduledtask.html',
pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtask',
view: 'dashboard/scheduledtasks/scheduledtask.html'
}
}, {
path: 'scheduledtasks.html',
pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtasks',
view: 'dashboard/scheduledtasks/scheduledtasks.html'
}
}, {
path: 'dashboard/activity',
pageProps: {
controller: 'dashboard/serveractivity',
view: 'dashboard/serveractivity.html'
}
}, {
path: 'apikeys.html',
pageProps: {
controller: 'dashboard/apikeys',
view: 'dashboard/apikeys.html'
}
}, {
path: 'streamingsettings.html',
pageProps: {
view: 'dashboard/streaming.html',
controller: 'dashboard/streaming'
}
}
];

View file

@ -1,3 +1,2 @@
export * from './admin';
export * from './public'; export * from './public';
export * from './user'; export * from './user';

View file

@ -49,12 +49,6 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
controller: 'user/home/index', controller: 'user/home/index',
view: 'user/home/index.html' view: 'user/home/index.html'
} }
}, {
path: 'mypreferencesquickconnect.html',
pageProps: {
controller: 'user/quickConnect/index',
view: 'user/quickConnect/index.html'
}
}, { }, {
path: 'mypreferencesplayback.html', path: 'mypreferencesplayback.html',
pageProps: { pageProps: {

View file

@ -0,0 +1,116 @@
import { getQuickConnectApi } from '@jellyfin/sdk/lib/utils/api/quick-connect-api';
import React, { FC, FormEvent, useCallback, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import Page from 'components/Page';
import globalize from 'scripts/globalize';
import InputElement from 'elements/InputElement';
import ButtonElement from 'elements/ButtonElement';
import { useApi } from 'hooks/useApi';
import './quickConnect.scss';
const QuickConnectPage: FC = () => {
const { api, user } = useApi();
const [ searchParams ] = useSearchParams();
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialValue = useMemo(() => searchParams.get('code') ?? '', []);
const [ code, setCode ] = useState(initialValue);
const [ error, setError ] = useState<string>();
const [ success, setSuccess ] = useState(false);
const onCodeChange = useCallback((value: string) => {
setCode(value);
}, []);
const onSubmitCode = useCallback((e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(undefined);
const form = e.currentTarget;
if (!form.checkValidity()) {
setError('QuickConnectInvalidCode');
return;
}
if (!api) {
console.error('[QuickConnect] cannot authorize, missing api instance');
setError('UnknownError');
return;
}
const userId = searchParams.get('userId') ?? user?.Id;
const normalizedCode = code.replace(/\s/g, '');
console.log('[QuickConnect] authorizing code %s as user %s', normalizedCode, userId);
getQuickConnectApi(api)
.authorizeQuickConnect({
code: normalizedCode,
userId
})
.then(() => {
setSuccess(true);
})
.catch(() => {
setError('QuickConnectAuthorizeFail');
});
}, [api, code, searchParams, user?.Id]);
return (
<Page
id='quickConnectPreferencesPage'
title={globalize.translate('QuickConnect')}
className='mainAnimatedPage libraryPage userPreferencesPage noSecondaryNavPage'
>
<div className='padded-left padded-right padded-bottom-page'>
<form onSubmit={onSubmitCode}>
<div className='verticalSection'>
<h2 className='sectionTitle'>
{globalize.translate('QuickConnect')}
</h2>
<div>
{globalize.translate('QuickConnectDescription')}
</div>
<br />
{error && (
<div className='quickConnectError'>
{globalize.translate(error)}
</div>
)}
{success ? (
<div style={{ textAlign: 'center' }}>
<p>
{globalize.translate('QuickConnectAuthorizeSuccess')}
</p>
<Link to='/home.html' className='button-link emby-button'>
{globalize.translate('GoHome')}
</Link>
</div>
) : (
<>
<InputElement
containerClassName='inputContainer'
initialValue={initialValue}
onChange={onCodeChange}
id='txtQuickConnectCode'
label='LabelQuickConnectCode'
type='text'
options="inputmode='numeric' pattern='[0-9\s]*' minlength='6' required autocomplete='off'"
/>
<ButtonElement
type='submit'
className='raised button-submit block'
title={globalize.translate('Authorize')}
/>
</>
)}
</div>
</form>
</div>
</Page>
);
};
export default QuickConnectPage;

View file

@ -0,0 +1,7 @@
.quickConnectError {
border-radius: 0.2em;
background-color: #160b0b;
color: #f4c7c3;
padding: 0.7em 0.5em;
margin-bottom: 1em;
}

View file

@ -0,0 +1,24 @@
import React, { FC, useEffect } from 'react';
import viewContainer from './viewContainer';
/**
* A simple component that includes the correct structure for ViewManager pages
* to exist alongside standard React pages.
*/
const AppBody: FC = ({ children }) => {
useEffect(() => () => {
// Reset view container state on unload
viewContainer.reset();
}, []);
return (
<>
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
{children}
</div>
</>
);
};
export default AppBody;

View file

@ -1,19 +1,29 @@
import React, { useEffect } from 'react'; import React, { FC, useEffect } from 'react';
const AppHeader = () => { interface AppHeaderParams {
isHidden?: boolean
}
const AppHeader: FC<AppHeaderParams> = ({
isHidden = false
}) => {
useEffect(() => { useEffect(() => {
// Initialize the UI components after first render // Initialize the UI components after first render
import('../scripts/libraryMenu'); import('../scripts/libraryMenu');
}, []); }, []);
return ( return (
<> /**
* NOTE: These components are not used with the new layouts, but legacy views interact with the elements
* directly so they need to be present in the DOM. We use display: none to hide them and prevent errors.
*/
<div style={isHidden ? { display: 'none' } : undefined}>
<div className='mainDrawer hide'> <div className='mainDrawer hide'>
<div className='mainDrawer-scrollContainer scrollContainer focuscontainer-y' /> <div className='mainDrawer-scrollContainer scrollContainer focuscontainer-y' />
</div> </div>
<div className='skinHeader focuscontainer-x' /> <div className='skinHeader focuscontainer-x' />
<div className='mainDrawerHandle' /> <div className='mainDrawerHandle' />
</> </div>
); );
}; };

View file

@ -5,14 +5,13 @@ import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import browser from 'scripts/browser'; import browser from 'scripts/browser';
import { DRAWER_WIDTH } from './AppDrawer'; export const DRAWER_WIDTH = 240;
import { isTabPath } from '../tabs/tabRoutes';
export interface ResponsiveDrawerProps { export interface ResponsiveDrawerProps {
hasSecondaryToolBar?: boolean
open: boolean open: boolean
onClose: () => void onClose: () => void
onOpen: () => void onOpen: () => void
@ -20,18 +19,17 @@ export interface ResponsiveDrawerProps {
const ResponsiveDrawer: FC<ResponsiveDrawerProps> = ({ const ResponsiveDrawer: FC<ResponsiveDrawerProps> = ({
children, children,
hasSecondaryToolBar = false,
open = false, open = false,
onClose, onClose,
onOpen onOpen
}) => { }) => {
const location = useLocation();
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')); const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg')); const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
const isTallToolbar = isTabPath(location.pathname) && !isLargeScreen;
const getToolbarStyles = useCallback((theme: Theme) => ({ const getToolbarStyles = useCallback((theme: Theme) => ({
marginBottom: isTallToolbar ? theme.spacing(6) : 0 marginBottom: (hasSecondaryToolBar && !isLargeScreen) ? theme.spacing(6) : 0
}), [ isTallToolbar ]); }), [ hasSecondaryToolBar, isLargeScreen ]);
return ( isSmallScreen ? ( return ( isSmallScreen ? (
/* DESKTOP DRAWER */ /* DESKTOP DRAWER */

View file

@ -5,24 +5,30 @@
*/ */
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import datetime from '../../scripts/datetime';
import imageLoader from '../images/imageLoader'; import cardBuilderUtils from './cardBuilderUtils';
import itemHelper from '../itemHelper'; import browser from 'scripts/browser';
import datetime from 'scripts/datetime';
import dom from 'scripts/dom';
import globalize from 'scripts/globalize';
import imageHelper from 'scripts/imagehelper';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { randomInt } from 'utils/number';
import focusManager from '../focusManager'; import focusManager from '../focusManager';
import imageLoader from '../images/imageLoader';
import indicators from '../indicators/indicators'; import indicators from '../indicators/indicators';
import globalize from '../../scripts/globalize'; import itemHelper from '../itemHelper';
import layoutManager from '../layoutManager'; import layoutManager from '../layoutManager';
import dom from '../../scripts/dom';
import browser from '../../scripts/browser';
import { playbackManager } from '../playback/playbackmanager'; import { playbackManager } from '../playback/playbackmanager';
import itemShortcuts from '../shortcuts';
import imageHelper from '../../scripts/imagehelper';
import { randomInt } from '../../utils/number.ts';
import './card.scss';
import '../../elements/emby-button/paper-icon-button-light';
import '../guide/programs.scss';
import ServerConnections from '../ServerConnections';
import { appRouter } from '../router/appRouter'; import { appRouter } from '../router/appRouter';
import ServerConnections from '../ServerConnections';
import itemShortcuts from '../shortcuts';
import 'elements/emby-button/paper-icon-button-light';
import './card.scss';
import '../guide/programs.scss';
const enableFocusTransform = !browser.slow && !browser.edge; const enableFocusTransform = !browser.slow && !browser.edge;
@ -41,217 +47,6 @@ export function getCardsHtml(items, options) {
return buildCardsHtmlInternal(items, options); return buildCardsHtmlInternal(items, options);
} }
/**
* Computes the number of posters per row.
* @param {string} shape - Shape of the cards.
* @param {number} screenWidth - Width of the screen.
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
* @returns {number} Number of cards per row for an itemsContainer.
*/
function getPostersPerRow(shape, screenWidth, isOrientationLandscape) {
switch (shape) {
case 'portrait':
if (layoutManager.tv) {
return 100 / 16.66666667;
}
if (screenWidth >= 2200) {
return 100 / 10;
}
if (screenWidth >= 1920) {
return 100 / 11.1111111111;
}
if (screenWidth >= 1600) {
return 100 / 12.5;
}
if (screenWidth >= 1400) {
return 100 / 14.28571428571;
}
if (screenWidth >= 1200) {
return 100 / 16.66666667;
}
if (screenWidth >= 800) {
return 5;
}
if (screenWidth >= 700) {
return 4;
}
if (screenWidth >= 500) {
return 100 / 33.33333333;
}
return 100 / 33.33333333;
case 'square':
if (layoutManager.tv) {
return 100 / 16.66666667;
}
if (screenWidth >= 2200) {
return 100 / 10;
}
if (screenWidth >= 1920) {
return 100 / 11.1111111111;
}
if (screenWidth >= 1600) {
return 100 / 12.5;
}
if (screenWidth >= 1400) {
return 100 / 14.28571428571;
}
if (screenWidth >= 1200) {
return 100 / 16.66666667;
}
if (screenWidth >= 800) {
return 5;
}
if (screenWidth >= 700) {
return 4;
}
if (screenWidth >= 500) {
return 100 / 33.33333333;
}
return 2;
case 'banner':
if (screenWidth >= 2200) {
return 100 / 25;
}
if (screenWidth >= 1200) {
return 100 / 33.33333333;
}
if (screenWidth >= 800) {
return 2;
}
return 1;
case 'backdrop':
if (layoutManager.tv) {
return 100 / 25;
}
if (screenWidth >= 2500) {
return 6;
}
if (screenWidth >= 1600) {
return 5;
}
if (screenWidth >= 1200) {
return 4;
}
if (screenWidth >= 770) {
return 3;
}
if (screenWidth >= 420) {
return 2;
}
return 1;
case 'smallBackdrop':
if (screenWidth >= 1600) {
return 100 / 12.5;
}
if (screenWidth >= 1400) {
return 100 / 14.2857142857;
}
if (screenWidth >= 1200) {
return 100 / 16.66666667;
}
if (screenWidth >= 1000) {
return 5;
}
if (screenWidth >= 800) {
return 4;
}
if (screenWidth >= 500) {
return 100 / 33.33333333;
}
return 2;
case 'overflowSmallBackdrop':
if (layoutManager.tv) {
return 100 / 18.9;
}
if (isOrientationLandscape) {
if (screenWidth >= 800) {
return 100 / 15.5;
}
return 100 / 23.3;
} else {
if (screenWidth >= 540) {
return 100 / 30;
}
return 100 / 72;
}
case 'overflowPortrait':
if (layoutManager.tv) {
return 100 / 15.5;
}
if (isOrientationLandscape) {
if (screenWidth >= 1700) {
return 100 / 11.6;
}
return 100 / 15.5;
} else {
if (screenWidth >= 1400) {
return 100 / 15;
}
if (screenWidth >= 1200) {
return 100 / 18;
}
if (screenWidth >= 760) {
return 100 / 23;
}
if (screenWidth >= 400) {
return 100 / 31.5;
}
return 100 / 42;
}
case 'overflowSquare':
if (layoutManager.tv) {
return 100 / 15.5;
}
if (isOrientationLandscape) {
if (screenWidth >= 1700) {
return 100 / 11.6;
}
return 100 / 15.5;
} else {
if (screenWidth >= 1400) {
return 100 / 15;
}
if (screenWidth >= 1200) {
return 100 / 18;
}
if (screenWidth >= 760) {
return 100 / 23;
}
if (screenWidth >= 540) {
return 100 / 31.5;
}
return 100 / 42;
}
case 'overflowBackdrop':
if (layoutManager.tv) {
return 100 / 23.3;
}
if (isOrientationLandscape) {
if (screenWidth >= 1700) {
return 100 / 18.5;
}
return 100 / 23.3;
} else {
if (screenWidth >= 1800) {
return 100 / 23.5;
}
if (screenWidth >= 1400) {
return 100 / 30;
}
if (screenWidth >= 760) {
return 100 / 40;
}
if (screenWidth >= 640) {
return 100 / 56;
}
return 100 / 72;
}
default:
return 4;
}
}
/** /**
* Checks if the window is resizable. * Checks if the window is resizable.
* @param {number} windowWidth - Width of the device's screen. * @param {number} windowWidth - Width of the device's screen.
@ -278,7 +73,7 @@ function isResizable(windowWidth) {
* @returns {number} Width of the image for a card. * @returns {number} Width of the image for a card.
*/ */
function getImageWidth(shape, screenWidth, isOrientationLandscape) { function getImageWidth(shape, screenWidth, isOrientationLandscape) {
const imagesPerRow = getPostersPerRow(shape, screenWidth, isOrientationLandscape); const imagesPerRow = cardBuilderUtils.getPostersPerRow(shape, screenWidth, isOrientationLandscape, layoutManager.tv);
return Math.round(screenWidth / imagesPerRow); return Math.round(screenWidth / imagesPerRow);
} }
@ -301,16 +96,16 @@ function setCardData(items, options) {
options.shape = 'banner'; options.shape = 'banner';
options.coverImage = true; options.coverImage = true;
} else if (primaryImageAspectRatio >= 1.33) { } else if (primaryImageAspectRatio >= 1.33) {
options.shape = requestedShape === 'autooverflow' ? 'overflowBackdrop' : 'backdrop'; options.shape = getBackdropShape(requestedShape === 'autooverflow');
} else if (primaryImageAspectRatio > 0.71) { } else if (primaryImageAspectRatio > 0.71) {
options.shape = requestedShape === 'autooverflow' ? 'overflowSquare' : 'square'; options.shape = getSquareShape(requestedShape === 'autooverflow');
} else { } else {
options.shape = requestedShape === 'autooverflow' ? 'overflowPortrait' : 'portrait'; options.shape = getPortraitShape(requestedShape === 'autooverflow');
} }
} }
if (!options.shape) { if (!options.shape) {
options.shape = options.defaultShape || (requestedShape === 'autooverflow' ? 'overflowSquare' : 'square'); options.shape = options.defaultShape || getSquareShape(requestedShape === 'autooverflow');
} }
} }
@ -318,7 +113,7 @@ function setCardData(items, options) {
options.preferThumb = options.shape === 'backdrop' || options.shape === 'overflowBackdrop'; options.preferThumb = options.shape === 'backdrop' || options.shape === 'overflowBackdrop';
} }
options.uiAspect = getDesiredAspect(options.shape); options.uiAspect = cardBuilderUtils.getDesiredAspect(options.shape);
options.primaryImageAspectRatio = primaryImageAspectRatio; options.primaryImageAspectRatio = primaryImageAspectRatio;
if (!options.width && options.widths) { if (!options.width && options.widths) {
@ -460,30 +255,6 @@ function buildCardsHtmlInternal(items, options) {
return html; return html;
} }
/**
* Computes the aspect ratio for a card given its shape.
* @param {string} shape - Shape for which to get the aspect ratio.
* @returns {null|number} Ratio of the shape.
*/
function getDesiredAspect(shape) {
if (shape) {
shape = shape.toLowerCase();
if (shape.indexOf('portrait') !== -1) {
return (2 / 3);
}
if (shape.indexOf('backdrop') !== -1) {
return (16 / 9);
}
if (shape.indexOf('square') !== -1) {
return 1;
}
if (shape.indexOf('banner') !== -1) {
return (1000 / 185);
}
}
return null;
}
/** /**
* @typedef {Object} CardImageUrl * @typedef {Object} CardImageUrl
* @property {string} imgUrl - Image URL. * @property {string} imgUrl - Image URL.
@ -509,7 +280,7 @@ function getCardImageUrl(item, apiClient, options, shape) {
let imgUrl = null; let imgUrl = null;
let imgTag = null; let imgTag = null;
let coverImage = false; let coverImage = false;
const uiAspect = getDesiredAspect(shape); const uiAspect = cardBuilderUtils.getDesiredAspect(shape);
let imgType = null; let imgType = null;
let itemId = null; let itemId = null;

View file

@ -0,0 +1,173 @@
const ASPECT_RATIOS = {
portrait: (2 / 3),
backdrop: (16 / 9),
square: 1,
banner: (1000 / 185)
};
/**
* Computes the aspect ratio for a card given its shape.
* @param {string} shape - Shape for which to get the aspect ratio.
* @returns {null|number} Ratio of the shape.
*/
function getDesiredAspect(shape) {
if (!shape) {
return null;
}
shape = shape.toLowerCase();
if (shape.indexOf('portrait') !== -1) {
return ASPECT_RATIOS.portrait;
}
if (shape.indexOf('backdrop') !== -1) {
return ASPECT_RATIOS.backdrop;
}
if (shape.indexOf('square') !== -1) {
return ASPECT_RATIOS.square;
}
if (shape.indexOf('banner') !== -1) {
return ASPECT_RATIOS.banner;
}
return null;
}
/**
* Computes the number of posters per row.
* @param {string} shape - Shape of the cards.
* @param {number} screenWidth - Width of the screen.
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
* @param {boolean} isTV - Flag to denote if posters are rendered on a television screen.
* @returns {number} Number of cards per row for an itemsContainer.
*/
function getPostersPerRow(shape, screenWidth, isOrientationLandscape, isTV) {
switch (shape) {
case 'portrait': return postersPerRowPortrait(screenWidth, isTV);
case 'square': return postersPerRowSquare(screenWidth, isTV);
case 'banner': return postersPerRowBanner(screenWidth);
case 'backdrop': return postersPerRowBackdrop(screenWidth, isTV);
case 'smallBackdrop': return postersPerRowSmallBackdrop(screenWidth);
case 'overflowSmallBackdrop': return postersPerRowOverflowSmallBackdrop(screenWidth, isOrientationLandscape, isTV);
case 'overflowPortrait': return postersPerRowOverflowPortrait(screenWidth, isOrientationLandscape, isTV);
case 'overflowSquare': return postersPerRowOverflowSquare(screenWidth, isOrientationLandscape, isTV);
case 'overflowBackdrop': return postersPerRowOverflowBackdrop(screenWidth, isOrientationLandscape, isTV);
default: return 4;
}
}
const postersPerRowPortrait = (screenWidth, isTV) => {
switch (true) {
case isTV: return 100 / 16.66666667;
case screenWidth >= 2200: return 10;
case screenWidth >= 1920: return 100 / 11.1111111111;
case screenWidth >= 1600: return 8;
case screenWidth >= 1400: return 100 / 14.28571428571;
case screenWidth >= 1200: return 100 / 16.66666667;
case screenWidth >= 800: return 5;
case screenWidth >= 700: return 4;
case screenWidth >= 500: return 100 / 33.33333333;
default: return 100 / 33.33333333;
}
};
const postersPerRowSquare = (screenWidth, isTV) => {
switch (true) {
case isTV: return 100 / 16.66666667;
case screenWidth >= 2200: return 10;
case screenWidth >= 1920: return 100 / 11.1111111111;
case screenWidth >= 1600: return 8;
case screenWidth >= 1400: return 100 / 14.28571428571;
case screenWidth >= 1200: return 100 / 16.66666667;
case screenWidth >= 800: return 5;
case screenWidth >= 700: return 4;
case screenWidth >= 500: return 100 / 33.33333333;
default: return 2;
}
};
const postersPerRowBanner = (screenWidth) => {
switch (true) {
case screenWidth >= 2200: return 4;
case screenWidth >= 1200: return 100 / 33.33333333;
case screenWidth >= 800: return 2;
default: return 1;
}
};
const postersPerRowBackdrop = (screenWidth, isTV) => {
switch (true) {
case isTV: return 4;
case screenWidth >= 2500: return 6;
case screenWidth >= 1600: return 5;
case screenWidth >= 1200: return 4;
case screenWidth >= 770: return 3;
case screenWidth >= 420: return 2;
default: return 1;
}
};
function postersPerRowSmallBackdrop(screenWidth) {
switch (true) {
case screenWidth >= 1600: return 8;
case screenWidth >= 1400: return 100 / 14.2857142857;
case screenWidth >= 1200: return 100 / 16.66666667;
case screenWidth >= 1000: return 5;
case screenWidth >= 800: return 4;
case screenWidth >= 500: return 100 / 33.33333333;
default: return 2;
}
}
const postersPerRowOverflowSmallBackdrop = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 18.9;
case isLandscape && screenWidth >= 800: return 100 / 15.5;
case isLandscape: return 100 / 23.3;
case screenWidth >= 540: return 100 / 30;
default: return 100 / 72;
}
};
const postersPerRowOverflowPortrait = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 15.5;
case isLandscape && screenWidth >= 1700: return 100 / 11.6;
case isLandscape: return 100 / 15.5;
case screenWidth >= 1400: return 100 / 15;
case screenWidth >= 1200: return 100 / 18;
case screenWidth >= 760: return 100 / 23;
case screenWidth >= 400: return 100 / 31.5;
default: return 100 / 42;
}
};
const postersPerRowOverflowSquare = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 15.5;
case isLandscape && screenWidth >= 1700: return 100 / 11.6;
case isLandscape: return 100 / 15.5;
case screenWidth >= 1400: return 100 / 15;
case screenWidth >= 1200: return 100 / 18;
case screenWidth >= 760: return 100 / 23;
case screenWidth >= 540: return 100 / 31.5;
default: return 100 / 42;
}
};
const postersPerRowOverflowBackdrop = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 23.3;
case isLandscape && screenWidth >= 1700: return 100 / 18.5;
case isLandscape: return 100 / 23.3;
case screenWidth >= 1800: return 100 / 23.5;
case screenWidth >= 1400: return 100 / 30;
case screenWidth >= 760: return 100 / 40;
case screenWidth >= 640: return 100 / 56;
default: return 100 / 72;
}
};
export default {
getDesiredAspect,
getPostersPerRow
};

View file

@ -0,0 +1,417 @@
import { describe, expect, test } from 'vitest';
import cardBuilderUtils from './cardBuilderUtils';
describe('getDesiredAspect', () => {
test('"portrait" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('portrait')).toEqual((2 / 3));
expect(cardBuilderUtils.getDesiredAspect('PorTRaIt')).toEqual((2 / 3));
});
test('"backdrop" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('backdrop')).toEqual((16 / 9));
expect(cardBuilderUtils.getDesiredAspect('BaCkDroP')).toEqual((16 / 9));
});
test('"square" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('square')).toEqual(1);
expect(cardBuilderUtils.getDesiredAspect('sQuArE')).toEqual(1);
});
test('"banner" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('banner')).toEqual((1000 / 185));
expect(cardBuilderUtils.getDesiredAspect('BaNnEr')).toEqual((1000 / 185));
});
test('invalid shape', () => {
expect(cardBuilderUtils.getDesiredAspect('invalid')).toBeNull();
});
test('shape is not provided', () => {
expect(cardBuilderUtils.getDesiredAspect('')).toBeNull();
});
});
describe('getPostersPerRow', () => {
test('resolves to default of 4 posters per row if shape is not provided', () => {
expect(cardBuilderUtils.getPostersPerRow('', 0, false, false)).toEqual(4);
});
describe('portrait', () => {
const postersPerRowForPortrait = (screenWidth, isTV) => (cardBuilderUtils.getPostersPerRow('portrait', screenWidth, false, isTV));
test('television', () => {
expect(postersPerRowForPortrait(0, true)).toEqual(100 / 16.66666667);
});
test('screen width less than 500px', () => {
expect(postersPerRowForPortrait(100, false)).toEqual(100 / 33.33333333);
expect(postersPerRowForPortrait(499, false)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 500px', () => {
expect(postersPerRowForPortrait(500, false)).toEqual(100 / 33.33333333);
expect(postersPerRowForPortrait(501, false)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 700px', () => {
expect(postersPerRowForPortrait(700, false)).toEqual(4);
expect(postersPerRowForPortrait(701, false)).toEqual(4);
});
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForPortrait(800, false)).toEqual(5);
expect(postersPerRowForPortrait(801, false)).toEqual(5);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForPortrait(1200, false)).toEqual(100 / 16.66666667);
expect(postersPerRowForPortrait(1201, false)).toEqual(100 / 16.66666667);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForPortrait(1400, false)).toEqual( 100 / 14.28571428571);
expect(postersPerRowForPortrait(1401, false)).toEqual( 100 / 14.28571428571);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForPortrait(1600, false)).toEqual( 8);
expect(postersPerRowForPortrait(1601, false)).toEqual( 8);
});
test('screen width greater or equal to 1920px', () => {
expect(postersPerRowForPortrait(1920, false)).toEqual( 100 / 11.1111111111);
expect(postersPerRowForPortrait(1921, false)).toEqual( 100 / 11.1111111111);
});
test('screen width greater or equal to 2200px', () => {
expect(postersPerRowForPortrait(2200, false)).toEqual( 10);
expect(postersPerRowForPortrait(2201, false)).toEqual( 10);
});
});
describe('square', () => {
const postersPerRowForSquare = (screenWidth, isTV) => (cardBuilderUtils.getPostersPerRow('square', screenWidth, false, isTV));
test('television', () => {
expect(postersPerRowForSquare(0, true)).toEqual(100 / 16.66666667);
});
test('screen width less than 500px', () => {
expect(postersPerRowForSquare(100, false)).toEqual(2);
expect(postersPerRowForSquare(499, false)).toEqual(2);
});
test('screen width greater or equal to 500px', () => {
expect(postersPerRowForSquare(500, false)).toEqual(100 / 33.33333333);
expect(postersPerRowForSquare(501, false)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 700px', () => {
expect(postersPerRowForSquare(700, false)).toEqual(4);
expect(postersPerRowForSquare(701, false)).toEqual(4);
});
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForSquare(800, false)).toEqual(5);
expect(postersPerRowForSquare(801, false)).toEqual(5);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForSquare(1200, false)).toEqual(100 / 16.66666667);
expect(postersPerRowForSquare(1201, false)).toEqual(100 / 16.66666667);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForSquare(1400, false)).toEqual( 100 / 14.28571428571);
expect(postersPerRowForSquare(1401, false)).toEqual( 100 / 14.28571428571);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForSquare(1600, false)).toEqual(8);
expect(postersPerRowForSquare(1601, false)).toEqual(8);
});
test('screen width greater or equal to 1920px', () => {
expect(postersPerRowForSquare(1920, false)).toEqual(100 / 11.1111111111);
expect(postersPerRowForSquare(1921, false)).toEqual(100 / 11.1111111111);
});
test('screen width greater or equal to 2200px', () => {
expect(postersPerRowForSquare(2200, false)).toEqual( 10);
expect(postersPerRowForSquare(2201, false)).toEqual( 10);
});
});
describe('banner', () => {
const postersPerRowForBanner = (screenWidth) => (cardBuilderUtils.getPostersPerRow('banner', screenWidth, false, false));
test('screen width less than 800px', () => {
expect(postersPerRowForBanner(799)).toEqual(1);
});
test('screen width greater than or equal to 800px', () => {
expect(postersPerRowForBanner(800)).toEqual(2);
expect(postersPerRowForBanner(801)).toEqual(2);
});
test('screen width greater than or equal to 1200px', () => {
expect(postersPerRowForBanner(1200)).toEqual(100 / 33.33333333);
expect(postersPerRowForBanner(1201)).toEqual(100 / 33.33333333);
});
test('screen width greater than or equal to 2200px', () => {
expect(postersPerRowForBanner(2200)).toEqual(4);
expect(postersPerRowForBanner(2201)).toEqual(4);
});
});
describe('backdrop', () => {
const postersPerRowForBackdrop = (screenWidth, isTV) => (cardBuilderUtils.getPostersPerRow('backdrop', screenWidth, false, isTV));
test('television', () => {
expect(postersPerRowForBackdrop(0, true)).toEqual(4);
});
test('screen width less than 420px', () => {
expect(postersPerRowForBackdrop(100, false)).toEqual(1);
expect(postersPerRowForBackdrop(419, false)).toEqual(1);
});
test('screen width greater or equal to 420px', () => {
expect(postersPerRowForBackdrop(420, false)).toEqual(2);
expect(postersPerRowForBackdrop(421, false)).toEqual(2);
});
test('screen width greater or equal to 770px', () => {
expect(postersPerRowForBackdrop(770, false)).toEqual(3);
expect(postersPerRowForBackdrop(771, false)).toEqual(3);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForBackdrop(1200, false)).toEqual(4);
expect(postersPerRowForBackdrop(1201, false)).toEqual(4);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForBackdrop(1600, false)).toEqual(5);
expect(postersPerRowForBackdrop(1601, false)).toEqual(5);
});
test('screen width greater or equal to 2500px', () => {
expect(postersPerRowForBackdrop(2500, false)).toEqual(6);
expect(postersPerRowForBackdrop(2501, false)).toEqual(6);
});
});
describe('small backdrop', () => {
const postersPerRowForSmallBackdrop = (screenWidth) => (cardBuilderUtils.getPostersPerRow('smallBackdrop', screenWidth, false, false));
test('screen width less than 500px', () => {
expect(postersPerRowForSmallBackdrop(100)).toEqual(2);
expect(postersPerRowForSmallBackdrop(499)).toEqual(2);
});
test('screen width greater or equal to 500px', () => {
expect(postersPerRowForSmallBackdrop(500)).toEqual(100 / 33.33333333);
expect(postersPerRowForSmallBackdrop(501)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForSmallBackdrop(800)).toEqual(4);
expect(postersPerRowForSmallBackdrop(801)).toEqual(4);
});
test('screen width greater or equal to 1000px', () => {
expect(postersPerRowForSmallBackdrop(1000)).toEqual(5);
expect(postersPerRowForSmallBackdrop(1001)).toEqual(5);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForSmallBackdrop(1200)).toEqual(100 / 16.66666667);
expect(postersPerRowForSmallBackdrop(1201)).toEqual(100 / 16.66666667);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForSmallBackdrop(1400)).toEqual(100 / 14.2857142857);
expect(postersPerRowForSmallBackdrop(1401)).toEqual(100 / 14.2857142857);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForSmallBackdrop(1600)).toEqual(8);
expect(postersPerRowForSmallBackdrop(1601)).toEqual(8);
});
});
describe('overflow small backdrop', () => {
const postersPerRowForOverflowSmallBackdrop = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowSmallBackdrop', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowSmallBackdrop(0, false, true)).toEqual( 100 / 18.9);
});
describe('non-landscape', () => {
test('screen width greater or equal to 540px', () => {
expect(postersPerRowForOverflowSmallBackdrop(540, false)).toEqual(100 / 30);
expect(postersPerRowForOverflowSmallBackdrop(541, false)).toEqual(100 / 30);
});
test('screen width is less than 540px', () => {
expect(postersPerRowForOverflowSmallBackdrop(539, false)).toEqual(100 / 72);
expect(postersPerRowForOverflowSmallBackdrop(100, false)).toEqual(100 / 72);
});
});
describe('landscape', () => {
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForOverflowSmallBackdrop(800, true)).toEqual(100 / 15.5);
expect(postersPerRowForOverflowSmallBackdrop(801, true)).toEqual(100 / 15.5);
});
test('screen width is less than 800px', () => {
expect(postersPerRowForOverflowSmallBackdrop(799, true)).toEqual(100 / 23.3);
expect(postersPerRowForOverflowSmallBackdrop(100, true)).toEqual(100 / 23.3);
});
});
});
describe('overflow portrait', () => {
const postersPerRowForOverflowPortrait = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowPortrait', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowPortrait(0, false, true)).toEqual( 100 / 15.5);
});
describe('non-landscape', () => {
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForOverflowPortrait(1400, false)).toEqual(100 / 15);
expect(postersPerRowForOverflowPortrait(1401, false)).toEqual(100 / 15);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForOverflowPortrait(1200, false)).toEqual(100 / 18);
expect(postersPerRowForOverflowPortrait(1201, false)).toEqual(100 / 18);
});
test('screen width greater or equal to 760px', () => {
expect(postersPerRowForOverflowPortrait(760, false)).toEqual(100 / 23);
expect(postersPerRowForOverflowPortrait(761, false)).toEqual(100 / 23);
});
test('screen width greater or equal to 400px', () => {
expect(postersPerRowForOverflowPortrait(400, false)).toEqual(100 / 31.5);
expect(postersPerRowForOverflowPortrait(401, false)).toEqual(100 / 31.5);
});
test('screen width is less than 400px', () => {
expect(postersPerRowForOverflowPortrait(399, false)).toEqual(100 / 42);
expect(postersPerRowForOverflowPortrait(100, false)).toEqual(100 / 42);
});
});
describe('landscape', () => {
test('screen width greater or equal to 1700px', () => {
expect(postersPerRowForOverflowPortrait(1700, true)).toEqual(100 / 11.6);
expect(postersPerRowForOverflowPortrait(1701, true)).toEqual(100 / 11.6);
});
test('screen width is less than 1700px', () => {
expect(postersPerRowForOverflowPortrait(1699, true)).toEqual(100 / 15.5);
expect(postersPerRowForOverflowPortrait(100, true)).toEqual(100 / 15.5);
});
});
});
describe('overflow square', () => {
const postersPerRowForOverflowSquare = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowSquare', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowSquare(0, false, true)).toEqual( 100 / 15.5);
});
describe('non-landscape', () => {
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForOverflowSquare(1400, false)).toEqual(100 / 15);
expect(postersPerRowForOverflowSquare(1401, false)).toEqual(100 / 15);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForOverflowSquare(1200, false)).toEqual(100 / 18);
expect(postersPerRowForOverflowSquare(1201, false)).toEqual(100 / 18);
});
test('screen width greater or equal to 760px', () => {
expect(postersPerRowForOverflowSquare(760, false)).toEqual(100 / 23);
expect(postersPerRowForOverflowSquare(761, false)).toEqual(100 / 23);
});
test('screen width greater or equal to 540px', () => {
expect(postersPerRowForOverflowSquare(540, false)).toEqual(100 / 31.5);
expect(postersPerRowForOverflowSquare(541, false)).toEqual(100 / 31.5);
});
test('screen width is less than 540px', () => {
expect(postersPerRowForOverflowSquare(539, false)).toEqual(100 / 42);
expect(postersPerRowForOverflowSquare(100, false)).toEqual(100 / 42);
});
});
describe('landscape', () => {
test('screen width greater or equal to 1700px', () => {
expect(postersPerRowForOverflowSquare(1700, true)).toEqual(100 / 11.6);
expect(postersPerRowForOverflowSquare(1701, true)).toEqual(100 / 11.6);
});
test('screen width is less than 1700px', () => {
expect(postersPerRowForOverflowSquare(1699, true)).toEqual(100 / 15.5);
expect(postersPerRowForOverflowSquare(100, true)).toEqual(100 / 15.5);
});
});
});
describe('overflow backdrop', () => {
const postersPerRowForOverflowBackdrop = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowBackdrop', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowBackdrop(0, false, true)).toEqual( 100 / 23.3);
});
describe('non-landscape', () => {
test('screen width greater or equal to 1800px', () => {
expect(postersPerRowForOverflowBackdrop(1800, false)).toEqual(100 / 23.5);
expect(postersPerRowForOverflowBackdrop(1801, false)).toEqual(100 / 23.5);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForOverflowBackdrop(1400, false)).toEqual(100 / 30);
expect(postersPerRowForOverflowBackdrop(1401, false)).toEqual(100 / 30);
});
test('screen width greater or equal to 760px', () => {
expect(postersPerRowForOverflowBackdrop(760, false)).toEqual(100 / 40);
expect(postersPerRowForOverflowBackdrop(761, false)).toEqual(100 / 40);
});
test('screen width greater or equal to 640px', () => {
expect(postersPerRowForOverflowBackdrop(640, false)).toEqual(100 / 56);
expect(postersPerRowForOverflowBackdrop(641, false)).toEqual(100 / 56);
});
test('screen width is less than 640px', () => {
expect(postersPerRowForOverflowBackdrop(639, false)).toEqual(100 / 72);
expect(postersPerRowForOverflowBackdrop(100, false)).toEqual(100 / 72);
});
});
describe('landscape', () => {
test('screen width greater or equal to 1700px', () => {
expect(postersPerRowForOverflowBackdrop(1700, true)).toEqual(100 / 18.5);
expect(postersPerRowForOverflowBackdrop(1701, true)).toEqual(100 / 18.5);
});
test('screen width is less than 1700px', () => {
expect(postersPerRowForOverflowBackdrop(1699, true)).toEqual(100 / 23.3);
expect(postersPerRowForOverflowBackdrop(100, true)).toEqual(100 / 23.3);
});
});
});
});

View file

@ -10,28 +10,28 @@ const createLinkElement = (activeTab: string) => ({
is="emby-linkbutton" is="emby-linkbutton"
data-role="button" data-role="button"
class="${activeTab === 'useredit' ? 'ui-btn-active' : ''}" class="${activeTab === 'useredit' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('useredit.html', true);"> onclick="Dashboard.navigate('/dashboard/users/profile', true);">
${globalize.translate('Profile')} ${globalize.translate('Profile')}
</a> </a>
<a href="#" <a href="#"
is="emby-linkbutton" is="emby-linkbutton"
data-role="button" data-role="button"
class="${activeTab === 'userlibraryaccess' ? 'ui-btn-active' : ''}" class="${activeTab === 'userlibraryaccess' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('userlibraryaccess.html', true);"> onclick="Dashboard.navigate('/dashboard/users/access', true);">
${globalize.translate('TabAccess')} ${globalize.translate('TabAccess')}
</a> </a>
<a href="#" <a href="#"
is="emby-linkbutton" is="emby-linkbutton"
data-role="button" data-role="button"
class="${activeTab === 'userparentalcontrol' ? 'ui-btn-active' : ''}" class="${activeTab === 'userparentalcontrol' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('userparentalcontrol.html', true);"> onclick="Dashboard.navigate('/dashboard/users/parentalcontrol', true);">
${globalize.translate('TabParentalControl')} ${globalize.translate('TabParentalControl')}
</a> </a>
<a href="#" <a href="#"
is="emby-linkbutton" is="emby-linkbutton"
data-role="button" data-role="button"
class="${activeTab === 'userpassword' ? 'ui-btn-active' : ''}" class="${activeTab === 'userpassword' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('userpassword.html', true);"> onclick="Dashboard.navigate('/dashboard/users/password', true);">
${globalize.translate('HeaderPassword')} ${globalize.translate('HeaderPassword')}
</a>` </a>`
}); });

View file

@ -11,7 +11,7 @@ const createLinkElement = ({ user, renderImgUrl }: { user: UserDto, renderImgUrl
__html: `<a __html: `<a
is="emby-linkbutton" is="emby-linkbutton"
class="cardContent" class="cardContent"
href="#/useredit.html?userId=${user.Id}" href="#/dashboard/users/profile?userId=${user.Id}"
> >
${renderImgUrl} ${renderImgUrl}
</a>` </a>`

View file

@ -36,7 +36,7 @@ function loadScreensavers(context, userSettings) {
const selectScreensaver = context.querySelector('.selectScreensaver'); const selectScreensaver = context.querySelector('.selectScreensaver');
const options = pluginManager.ofType(PluginType.Screensaver).map(plugin => { const options = pluginManager.ofType(PluginType.Screensaver).map(plugin => {
return { return {
name: plugin.name, name: globalize.translate(plugin.name),
value: plugin.id value: plugin.id
}; };
}); });

View file

@ -1,50 +1,42 @@
import loading from './loading/loading'; import dom from 'scripts/dom';
import cardBuilder from './cardbuilder/cardBuilder'; import globalize from 'scripts/globalize';
import dom from '../scripts/dom'; import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { getParameterByName } from 'utils/url';
import { appHost } from './apphost'; import { appHost } from './apphost';
import cardBuilder from './cardbuilder/cardBuilder';
import imageLoader from './images/imageLoader'; import imageLoader from './images/imageLoader';
import globalize from '../scripts/globalize';
import layoutManager from './layoutManager'; import layoutManager from './layoutManager';
import { getParameterByName } from '../utils/url.ts'; import loading from './loading/loading';
import '../styles/scrollstyles.scss';
import '../elements/emby-itemscontainer/emby-itemscontainer'; import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'styles/scrollstyles.scss';
function enableScrollX() { function enableScrollX() {
return !layoutManager.desktop; return !layoutManager.desktop;
} }
function getThumbShape() {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}
function getPosterShape() {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}
function getSquareShape() {
return enableScrollX() ? 'overflowSquare' : 'square';
}
function getSections() { function getSections() {
return [{ return [{
name: 'Movies', name: 'Movies',
types: 'Movie', types: 'Movie',
id: 'favoriteMovies', id: 'favoriteMovies',
shape: getPosterShape(), shape: getPortraitShape(enableScrollX()),
showTitle: false, showTitle: false,
overlayPlayButton: true overlayPlayButton: true
}, { }, {
name: 'Shows', name: 'Shows',
types: 'Series', types: 'Series',
id: 'favoriteShows', id: 'favoriteShows',
shape: getPosterShape(), shape: getPortraitShape(enableScrollX()),
showTitle: false, showTitle: false,
overlayPlayButton: true overlayPlayButton: true
}, { }, {
name: 'Episodes', name: 'Episodes',
types: 'Episode', types: 'Episode',
id: 'favoriteEpisode', id: 'favoriteEpisode',
shape: getThumbShape(), shape: getBackdropShape(enableScrollX()),
preferThumb: false, preferThumb: false,
showTitle: true, showTitle: true,
showParentTitle: true, showParentTitle: true,
@ -55,7 +47,7 @@ function getSections() {
name: 'Videos', name: 'Videos',
types: 'Video,MusicVideo', types: 'Video,MusicVideo',
id: 'favoriteVideos', id: 'favoriteVideos',
shape: getThumbShape(), shape: getBackdropShape(enableScrollX()),
preferThumb: true, preferThumb: true,
showTitle: true, showTitle: true,
overlayPlayButton: true, overlayPlayButton: true,
@ -65,7 +57,7 @@ function getSections() {
name: 'Artists', name: 'Artists',
types: 'MusicArtist', types: 'MusicArtist',
id: 'favoriteArtists', id: 'favoriteArtists',
shape: getSquareShape(), shape: getSquareShape(enableScrollX()),
preferThumb: false, preferThumb: false,
showTitle: true, showTitle: true,
overlayText: false, overlayText: false,
@ -77,7 +69,7 @@ function getSections() {
name: 'Albums', name: 'Albums',
types: 'MusicAlbum', types: 'MusicAlbum',
id: 'favoriteAlbums', id: 'favoriteAlbums',
shape: getSquareShape(), shape: getSquareShape(enableScrollX()),
preferThumb: false, preferThumb: false,
showTitle: true, showTitle: true,
overlayText: false, overlayText: false,
@ -89,7 +81,7 @@ function getSections() {
name: 'Songs', name: 'Songs',
types: 'Audio', types: 'Audio',
id: 'favoriteSongs', id: 'favoriteSongs',
shape: getSquareShape(), shape: getSquareShape(enableScrollX()),
preferThumb: false, preferThumb: false,
showTitle: true, showTitle: true,
overlayText: false, overlayText: false,

View file

@ -1,18 +1,23 @@
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import globalize from 'scripts/globalize';
import imageHelper from 'scripts/imagehelper';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import Dashboard from 'utils/dashboard';
import cardBuilder from '../cardbuilder/cardBuilder'; import cardBuilder from '../cardbuilder/cardBuilder';
import layoutManager from '../layoutManager';
import imageLoader from '../images/imageLoader'; import imageLoader from '../images/imageLoader';
import globalize from '../../scripts/globalize'; import layoutManager from '../layoutManager';
import { appRouter } from '../router/appRouter'; import { appRouter } from '../router/appRouter';
import imageHelper from '../../scripts/imagehelper';
import '../../elements/emby-button/paper-icon-button-light';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-button/emby-button';
import './homesections.scss';
import Dashboard from '../../utils/dashboard';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
import 'elements/emby-button/paper-icon-button-light';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-scroller/emby-scroller';
import 'elements/emby-button/emby-button';
import './homesections.scss';
export function getDefaultSection(index) { export function getDefaultSection(index) {
switch (index) { switch (index) {
case 0: case 0:
@ -69,11 +74,14 @@ export function loadSections(elem, apiClient, user, userSettings) {
promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, i)); promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, i));
} }
return Promise.all(promises).then(function () { return Promise.all(promises)
return resume(elem, { // Timeout for polyfilled CustomElements (webOS 1.2)
refresh: true .then(() => new Promise((resolve) => setTimeout(resolve, 0)))
.then(() => {
return resume(elem, {
refresh: true
});
}); });
});
} else { } else {
let noLibDescription; let noLibDescription;
if (user.Policy?.IsAdministrator) { if (user.Policy?.IsAdministrator) {
@ -91,7 +99,7 @@ export function loadSections(elem, apiClient, user, userSettings) {
const createNowLink = elem.querySelector('#button-createLibrary'); const createNowLink = elem.querySelector('#button-createLibrary');
if (createNowLink) { if (createNowLink) {
createNowLink.addEventListener('click', function () { createNowLink.addEventListener('click', function () {
Dashboard.navigate('library.html'); Dashboard.navigate('dashboard/libraries');
}); });
} }
} }
@ -166,18 +174,6 @@ function enableScrollX() {
return true; return true;
} }
function getSquareShape() {
return enableScrollX() ? 'overflowSquare' : 'square';
}
function getThumbShape() {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}
function getPortraitShape() {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}
function getLibraryButtonsHtml(items) { function getLibraryButtonsHtml(items) {
let html = ''; let html = '';
@ -241,11 +237,11 @@ function getLatestItemsHtmlFn(itemType, viewType) {
const cardLayout = false; const cardLayout = false;
let shape; let shape;
if (itemType === 'Channel' || viewType === 'movies' || viewType === 'books' || viewType === 'tvshows') { if (itemType === 'Channel' || viewType === 'movies' || viewType === 'books' || viewType === 'tvshows') {
shape = getPortraitShape(); shape = getPortraitShape(enableScrollX());
} else if (viewType === 'music' || viewType === 'homevideos') { } else if (viewType === 'music' || viewType === 'homevideos') {
shape = getSquareShape(); shape = getSquareShape(enableScrollX());
} else { } else {
shape = getThumbShape(); shape = getBackdropShape(enableScrollX());
} }
return cardBuilder.getCardsHtml({ return cardBuilder.getCardsHtml({
@ -342,7 +338,7 @@ export function loadLibraryTiles(elem, apiClient, user, userSettings, shape, use
html += cardBuilder.getCardsHtml({ html += cardBuilder.getCardsHtml({
items: userViews, items: userViews,
shape: getThumbShape(), shape: getBackdropShape(enableScrollX()),
showTitle: true, showTitle: true,
centerText: true, centerText: true,
overlayText: false, overlayText: false,
@ -420,7 +416,9 @@ function getItemsToResumeHtmlFn(useEpisodeImages, mediaType) {
items: items, items: items,
preferThumb: true, preferThumb: true,
inheritThumb: !useEpisodeImages, inheritThumb: !useEpisodeImages,
shape: (mediaType === 'Book') ? getPortraitShape() : getThumbShape(), shape: (mediaType === 'Book') ?
getPortraitShape(enableScrollX()) :
getBackdropShape(enableScrollX()),
overlayText: false, overlayText: false,
showTitle: true, showTitle: true,
showParentTitle: true, showParentTitle: true,
@ -468,7 +466,7 @@ function getOnNowItemsHtml(items) {
showChannelName: false, showChannelName: false,
showAirDateTime: false, showAirDateTime: false,
showAirEndTime: true, showAirEndTime: true,
defaultShape: getThumbShape(), defaultShape: getBackdropShape(enableScrollX()),
lines: 3, lines: 3,
overlayPlayButton: true overlayPlayButton: true
}); });
@ -611,7 +609,7 @@ function getNextUpItemsHtmlFn(useEpisodeImages) {
items: items, items: items,
preferThumb: true, preferThumb: true,
inheritThumb: !useEpisodeImages, inheritThumb: !useEpisodeImages,
shape: getThumbShape(), shape: getBackdropShape(enableScrollX()),
overlayText: false, overlayText: false,
showTitle: true, showTitle: true,
showParentTitle: true, showParentTitle: true,

View file

@ -445,7 +445,7 @@ function executeCommand(item, id, options) {
}); });
break; break;
case 'multiSelect': case 'multiSelect':
import('./multiSelect/multiSelect').then(({ startMultiSelect: startMultiSelect }) => { import('./multiSelect/multiSelect').then(({ startMultiSelect }) => {
const card = dom.parentWithClass(options.positionTo, 'card'); const card = dom.parentWithClass(options.positionTo, 'card');
startMultiSelect(card); startMultiSelect(card);
}); });

View file

@ -1,4 +1,4 @@
import loadable from '@loadable/component'; import loadable, { LoadableComponent } from '@loadable/component';
import React from 'react'; import React from 'react';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
@ -10,13 +10,18 @@ export enum AsyncRouteType {
export interface AsyncRoute { export interface AsyncRoute {
/** The URL path for this route. */ /** The URL path for this route. */
path: string path: string
/** The relative path to the page component in the routes directory. */ /**
page: string * The relative path to the page component in the routes directory.
/** The route should use the page component from the experimental app. */ * Will fallback to using the `path` value if not specified.
*/
page?: string
/** The page element to render. */
element?: LoadableComponent<AsyncPageProps>
/** The page type used to load the correct page element. */
type?: AsyncRouteType type?: AsyncRouteType
} }
interface AsyncPageProps { export interface AsyncPageProps {
/** The relative path to the page component in the routes directory. */ /** The relative path to the page component in the routes directory. */
page: string page: string
} }
@ -31,14 +36,19 @@ const StableAsyncPage = loadable(
{ cacheKey: (props: AsyncPageProps) => props.page } { cacheKey: (props: AsyncPageProps) => props.page }
); );
export const toAsyncPageRoute = ({ path, page, type = AsyncRouteType.Stable }: AsyncRoute) => ( export const toAsyncPageRoute = ({ path, page, element, type = AsyncRouteType.Stable }: AsyncRoute) => {
<Route const Element = element
key={path} || (
path={path}
element={(
type === AsyncRouteType.Experimental ? type === AsyncRouteType.Experimental ?
<ExperimentalAsyncPage page={page} /> : ExperimentalAsyncPage :
<StableAsyncPage page={page} /> StableAsyncPage
)} );
/>
); return (
<Route
key={path}
path={path}
element={<Element page={page ?? path} />}
/>
);
};

View file

@ -0,0 +1,28 @@
import React from 'react';
import { Navigate, Route, useLocation } from 'react-router-dom';
export interface Redirect {
from: string
to: string
}
const RedirectWithSearch = ({ to }: { to: string }) => {
const { search } = useLocation();
return (
<Navigate
replace
to={`${to}${search}`}
/>
);
};
export function toRedirectRoute({ from, to }: Redirect) {
return (
<Route
key={from}
path={from}
element={<RedirectWithSearch to={to} />}
/>
);
}

View file

@ -527,7 +527,7 @@ class AppRouter {
} }
if (item === 'manageserver') { if (item === 'manageserver') {
return '#/dashboard.html'; return '#/dashboard';
} }
if (item === 'recordedtv') { if (item === 'recordedtv') {

View file

@ -46,15 +46,8 @@ function init(instance) {
if (inputOffset) { if (inputOffset) {
inputOffset = inputOffset[0]; inputOffset = inputOffset[0];
inputOffset = parseFloat(inputOffset); inputOffset = parseFloat(inputOffset);
inputOffset = Math.min(30, Math.max(-30, inputOffset));
// replace current text by considered offset subtitleSyncSlider.updateOffset(inputOffset);
this.textContent = inputOffset + 's';
// set new offset
playbackManager.setSubtitleOffset(inputOffset, player);
// synchronize with slider value
subtitleSyncSlider.updateOffset(
getSliderValueFromOffset(inputOffset));
} else { } else {
this.textContent = (playbackManager.getPlayerSubtitleOffset(player) || 0) + 's'; this.textContent = (playbackManager.getPlayerSubtitleOffset(player) || 0) + 's';
} }
@ -79,23 +72,26 @@ function init(instance) {
} }
}; };
function updateSubtitleOffset() {
const value = parseFloat(subtitleSyncSlider.value);
// set new offset
playbackManager.setSubtitleOffset(value, player);
// synchronize with textField value
subtitleSyncTextField.updateOffset(value);
}
subtitleSyncSlider.updateOffset = function (sliderValue) { subtitleSyncSlider.updateOffset = function (sliderValue) {
// default value is 0s = 0ms // default value is 0s = 0ms
this.value = sliderValue === undefined ? 0 : sliderValue; this.value = sliderValue === undefined ? 0 : sliderValue;
updateSubtitleOffset();
}; };
subtitleSyncSlider.addEventListener('change', function () { subtitleSyncSlider.addEventListener('change', () => updateSubtitleOffset());
// set new offset
playbackManager.setSubtitleOffset(getOffsetFromSliderValue(this.value), player);
// synchronize with textField value
subtitleSyncTextField.updateOffset(
getOffsetFromSliderValue(this.value));
});
subtitleSyncSlider.getBubbleHtml = function (value) { subtitleSyncSlider.getBubbleHtml = function (_, value) {
const newOffset = getOffsetFromPercentage(value);
return '<h1 class="sliderBubbleText">' return '<h1 class="sliderBubbleText">'
+ (newOffset > 0 ? '+' : '') + parseFloat(newOffset) + 's' + (value > 0 ? '+' : '') + parseFloat(value) + 's'
+ '</h1>'; + '</h1>';
}; };
@ -107,25 +103,6 @@ function init(instance) {
instance.element = parent; instance.element = parent;
} }
function getOffsetFromPercentage(value) {
// convert percentage to fraction
let offset = (value - 50) / 50;
// multiply by offset min/max range value (-x to +x) :
offset *= 30;
return offset.toFixed(1);
}
function getOffsetFromSliderValue(value) {
// convert slider value to offset
const offset = value / 10;
return offset.toFixed(1);
}
function getSliderValueFromOffset(value) {
const sliderValue = value * 10;
return Math.min(300, Math.max(-300, sliderValue.toFixed(1)));
}
class SubtitleSync { class SubtitleSync {
constructor(currentPlayer) { constructor(currentPlayer) {
player = currentPlayer; player = currentPlayer;

View file

@ -3,7 +3,7 @@
<button type="button" is="paper-icon-button-light" class="subtitleSync-closeButton"><span class="material-icons close" aria-hidden="true"></span></button> <button type="button" is="paper-icon-button-light" class="subtitleSync-closeButton"><span class="material-icons close" aria-hidden="true"></span></button>
<div class="subtitleSyncTextField" contenteditable="true" spellcheck="false">0s</div> <div class="subtitleSyncTextField" contenteditable="true" spellcheck="false">0s</div>
<div class="sliderContainer subtitleSyncSliderContainer"> <div class="sliderContainer subtitleSyncSliderContainer">
<input is="emby-slider" type="range" step="1" min="-300" max="300" value="0" class="subtitleSyncSlider" data-slider-keep-progress="true" /> <input is="emby-slider" type="range" step="0.1" min="-30" max="30" value="0" class="subtitleSyncSlider" data-slider-keep-progress="true" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,7 @@
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import { getSubtitleApi } from '@jellyfin/sdk/lib/utils/api/subtitle-api';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import dialogHelper from '../../components/dialogHelper/dialogHelper'; import dialogHelper from '../../components/dialogHelper/dialogHelper';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
import dom from '../../scripts/dom'; import dom from '../../scripts/dom';
@ -13,6 +15,7 @@ import '../../elements/emby-button/emby-button';
import '../../elements/emby-select/emby-select'; import '../../elements/emby-select/emby-select';
import '../formdialog.scss'; import '../formdialog.scss';
import './style.scss'; import './style.scss';
import { readFileAsBase64 } from 'utils/file';
let currentItemId; let currentItemId;
let currentServerId; let currentServerId;
@ -75,7 +78,7 @@ function setFiles(page, files) {
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
function onSubmit(e) { async function onSubmit(e) {
const file = currentFile; const file = currentFile;
if (!isValidSubtitleFile(file)) { if (!isValidSubtitleFile(file)) {
@ -89,8 +92,17 @@ function onSubmit(e) {
const dlg = dom.parentWithClass(this, 'dialog'); const dlg = dom.parentWithClass(this, 'dialog');
const language = dlg.querySelector('#selectLanguage').value; const language = dlg.querySelector('#selectLanguage').value;
const isForced = dlg.querySelector('#chkIsForced').checked; const isForced = dlg.querySelector('#chkIsForced').checked;
const isHearingImpaired = dlg.querySelector('#chkIsHearingImpaired').checked;
ServerConnections.getApiClient(currentServerId).uploadItemSubtitle(currentItemId, language, isForced, file).then(function () { const subtitleApi = getSubtitleApi(toApi(ServerConnections.getApiClient(currentServerId)));
const data = await readFileAsBase64(file);
const format = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase();
subtitleApi.uploadSubtitle({
itemId: currentItemId,
uploadSubtitleDto: { Data: data, Language: language, IsForced: isForced, Format: format, IsHearingImpaired: isHearingImpaired }
}).then(function () {
dlg.querySelector('#uploadSubtitle').value = ''; dlg.querySelector('#uploadSubtitle').value = '';
loading.hide(); loading.hide();
hasChanges = true; hasChanges = true;

View file

@ -31,6 +31,10 @@
<input type="checkbox" is="emby-checkbox" id="chkIsForced" /> <input type="checkbox" is="emby-checkbox" id="chkIsForced" />
<span>${LabelIsForced}</span> <span>${LabelIsForced}</span>
</label> </label>
<label>
<input type="checkbox" is="emby-checkbox" id="chkIsHearingImpaired" />
<span>${LabelIsHearingImpaired}</span>
</label>
</div> </div>
<div class="selectContainer flex-grow"> <div class="selectContainer flex-grow">
<select is="emby-select" id="selectLanguage" required="required" label="${LabelLanguage}"></select> <select is="emby-select" id="selectLanguage" required="required" label="${LabelLanguage}"></select>

View file

@ -0,0 +1,129 @@
import ArrowBack from '@mui/icons-material/ArrowBack';
import MenuIcon from '@mui/icons-material/Menu';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import React, { FC, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import appIcon from 'assets/img/icon-transparent.png';
import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import UserMenuButton from './UserMenuButton';
interface AppToolbarProps {
buttons?: ReactNode
isDrawerAvailable: boolean
isDrawerOpen: boolean
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
}
const onBackButtonClick = () => {
appRouter.back()
.catch(err => {
console.error('[AppToolbar] error calling appRouter.back', err);
});
};
const AppToolbar: FC<AppToolbarProps> = ({
buttons,
children,
isDrawerAvailable,
isDrawerOpen,
onDrawerButtonClick
}) => {
const { user } = useApi();
const isUserLoggedIn = Boolean(user);
const isBackButtonAvailable = appRouter.canGoBack();
return (
<Toolbar
variant='dense'
sx={{
flexWrap: {
xs: 'wrap',
lg: 'nowrap'
}
}}
>
{isUserLoggedIn && isDrawerAvailable && (
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
<IconButton
size='large'
edge='start'
color='inherit'
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
onClick={onDrawerButtonClick}
>
<MenuIcon />
</IconButton>
</Tooltip>
)}
{isBackButtonAvailable && (
<Tooltip title={globalize.translate('ButtonBack')}>
<IconButton
size='large'
// Set the edge if the drawer button is not shown
edge={!(isUserLoggedIn && isDrawerAvailable) ? 'start' : undefined}
color='inherit'
aria-label={globalize.translate('ButtonBack')}
onClick={onBackButtonClick}
>
<ArrowBack />
</IconButton>
</Tooltip>
)}
<Box
component={Link}
to='/'
color='inherit'
aria-label={globalize.translate('Home')}
sx={{
ml: 2,
display: 'inline-flex',
textDecoration: 'none'
}}
>
<Box
component='img'
src={appIcon}
sx={{
height: '2rem',
marginInlineEnd: 1
}}
/>
<Typography
variant='h6'
noWrap
component='div'
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
>
Jellyfin
</Typography>
</Box>
{children}
{isUserLoggedIn && (
<>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
{buttons}
</Box>
<Box sx={{ flexGrow: 0 }}>
<UserMenuButton />
</Box>
</>
)}
</Toolbar>
);
};
export default AppToolbar;

View file

@ -115,7 +115,7 @@ const AppUserMenu: FC<AppUserMenuProps> = ({
<MenuItem <MenuItem
key='admin-dashboard-link' key='admin-dashboard-link'
component={Link} component={Link}
to='/dashboard.html' to='/dashboard'
onClick={onMenuClose} onClick={onMenuClose}
> >
@ -127,7 +127,7 @@ const AppUserMenu: FC<AppUserMenuProps> = ({
<MenuItem <MenuItem
key='admin-metadata-link' key='admin-metadata-link'
component={Link} component={Link}
to='/edititemmetadata.html' to='/metadata'
onClick={onMenuClose} onClick={onMenuClose}
> >
<ListItemIcon> <ListItemIcon>
@ -140,7 +140,7 @@ const AppUserMenu: FC<AppUserMenuProps> = ({
<Divider /> <Divider />
<MenuItem <MenuItem
component={Link} component={Link}
to='/mypreferencesquickconnect.html' to='/quickconnect'
onClick={onMenuClose} onClick={onMenuClose}
> >
<ListItemIcon> <ListItemIcon>

View file

@ -2,11 +2,11 @@ import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import UserAvatar from 'components/UserAvatar';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import AppUserMenu, { ID } from './menus/AppUserMenu'; import AppUserMenu, { ID } from './AppUserMenu';
import UserAvatar from 'components/UserAvatar';
const UserMenuButton = () => { const UserMenuButton = () => {
const { user } = useApi(); const { user } = useApi();

View file

@ -3,11 +3,7 @@ import './viewManager/viewContainer.scss';
import Dashboard from '../utils/dashboard'; import Dashboard from '../utils/dashboard';
const getMainAnimatedPages = () => { const getMainAnimatedPages = () => {
if (!mainAnimatedPages) { return document.querySelector('.mainAnimatedPages');
mainAnimatedPages = document.querySelector('.mainAnimatedPages');
}
return mainAnimatedPages;
}; };
function setControllerClass(view, options) { function setControllerClass(view, options) {
@ -61,7 +57,9 @@ export function loadView(options) {
view.classList.add('mainAnimatedPage'); view.classList.add('mainAnimatedPage');
if (!getMainAnimatedPages()) { const mainAnimatedPages = getMainAnimatedPages();
if (!mainAnimatedPages) {
console.warn('[viewContainer] main animated pages element is not present'); console.warn('[viewContainer] main animated pages element is not present');
return; return;
} }
@ -187,6 +185,7 @@ export function setOnBeforeChange(fn) {
} }
export function tryRestoreView(options) { export function tryRestoreView(options) {
console.debug('[viewContainer] tryRestoreView', options);
const url = options.url; const url = options.url;
const index = currentUrls.indexOf(url); const index = currentUrls.indexOf(url);
@ -232,14 +231,15 @@ function triggerDestroy(view) {
} }
export function reset() { export function reset() {
console.debug('[viewContainer] resetting view cache');
allPages = []; allPages = [];
currentUrls = []; currentUrls = [];
const mainAnimatedPages = getMainAnimatedPages();
if (mainAnimatedPages) mainAnimatedPages.innerHTML = ''; if (mainAnimatedPages) mainAnimatedPages.innerHTML = '';
selectedPageIndex = -1; selectedPageIndex = -1;
} }
let onBeforeChange; let onBeforeChange;
let mainAnimatedPages;
let allPages = []; let allPages = [];
let currentUrls = []; let currentUrls = [];
const pageContainerCount = 3; const pageContainerCount = 3;
@ -248,8 +248,8 @@ reset();
getMainAnimatedPages()?.classList.remove('hide'); getMainAnimatedPages()?.classList.remove('hide');
export default { export default {
loadView: loadView, loadView,
tryRestoreView: tryRestoreView, tryRestoreView,
reset: reset, reset,
setOnBeforeChange: setOnBeforeChange setOnBeforeChange
}; };

View file

@ -3,7 +3,7 @@
<div class="dashboardSections" style="padding-top:.5em;"> <div class="dashboardSections" style="padding-top:.5em;">
<div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46"> <div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46">
<div class="dashboardSection"> <div class="dashboardSection">
<a is="emby-linkbutton" href="#/dashboardgeneral.html" class="button-flat sectionTitleTextButton"> <a is="emby-linkbutton" href="#/dashboard/settings" class="button-flat sectionTitleTextButton">
<h3>${TabServer}</h3> <h3>${TabServer}</h3>
<span class="material-icons chevron_right" aria-hidden="true"></span> <span class="material-icons chevron_right" aria-hidden="true"></span>
</a> </a>
@ -33,7 +33,7 @@
</div> </div>
<div class="dashboardSection"> <div class="dashboardSection">
<a is="emby-linkbutton" href="#/devices.html" class="button-flat sectionTitleTextButton"> <a is="emby-linkbutton" href="#/dashboard/devices" class="button-flat sectionTitleTextButton">
<h3>${HeaderActiveDevices}</h3> <h3>${HeaderActiveDevices}</h3>
<span class="material-icons chevron_right" aria-hidden="true"></span> <span class="material-icons chevron_right" aria-hidden="true"></span>
</a> </a>
@ -70,7 +70,7 @@
</div> </div>
<div class="dashboardSection"> <div class="dashboardSection">
<a is="emby-linkbutton" href="#/dashboardgeneral.html" class="button-flat sectionTitleTextButton"> <a is="emby-linkbutton" href="#/dashboard/settings" class="button-flat sectionTitleTextButton">
<h3>${HeaderPaths}</h3> <h3>${HeaderPaths}</h3>
<span class="material-icons chevron_right" aria-hidden="true"></span> <span class="material-icons chevron_right" aria-hidden="true"></span>
</a> </a>

View file

@ -73,7 +73,7 @@ function showDeviceMenu(view, btn, deviceId) {
callback: function (id) { callback: function (id) {
switch (id) { switch (id) {
case 'open': case 'open':
Dashboard.navigate('device.html?id=' + deviceId); Dashboard.navigate('dashboard/devices/edit?id=' + deviceId);
break; break;
case 'delete': case 'delete':
@ -94,7 +94,7 @@ function load(page, devices) {
deviceHtml += '<div class="cardBox visualCardBox">'; deviceHtml += '<div class="cardBox visualCardBox">';
deviceHtml += '<div class="cardScalable">'; deviceHtml += '<div class="cardScalable">';
deviceHtml += '<div class="cardPadder cardPadder-backdrop"></div>'; deviceHtml += '<div class="cardPadder cardPadder-backdrop"></div>';
deviceHtml += `<a is="emby-linkbutton" href="#!/device.html?id=${escapeHtml(device.Id)}" class="cardContent cardImageContainer ${cardBuilder.getDefaultBackgroundClass()}">`; deviceHtml += `<a is="emby-linkbutton" href="#/dashboard/devices/edit?id=${escapeHtml(device.Id)}" class="cardContent cardImageContainer ${cardBuilder.getDefaultBackgroundClass()}">`;
// audit note: getDeviceIcon returns static text // audit note: getDeviceIcon returns static text
const iconUrl = imageHelper.getDeviceIcon(device); const iconUrl = imageHelper.getDeviceIcon(device);

View file

@ -264,7 +264,7 @@
<button is="emby-button" type="submit" class="raised button-submit block"> <button is="emby-button" type="submit" class="raised button-submit block">
<span>${Save}</span> <span>${Save}</span>
</button> </button>
<button is="emby-button" type="button" class="button-cancel raised block" onclick="Dashboard.navigate('dlnaprofiles.html');"> <button is="emby-button" type="button" class="button-cancel raised block" onclick="Dashboard.navigate('dashboard/dlna/profiles');">
<span>${ButtonCancel}</span> <span>${ButtonCancel}</span>
</button> </button>
</div> </div>

View file

@ -639,7 +639,7 @@ function saveProfile(page, profile) {
data: JSON.stringify(profile), data: JSON.stringify(profile),
contentType: 'application/json' contentType: 'application/json'
}).then(function () { }).then(function () {
Dashboard.navigate('dlnaprofiles.html'); Dashboard.navigate('dashboard/dlna/profiles');
}, Dashboard.processErrorResponse); }, Dashboard.processErrorResponse);
} }

View file

@ -8,7 +8,7 @@
<div class="verticalSection verticalSection-extrabottompadding"> <div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${HeaderCustomDlnaProfiles}</h2> <h2 class="sectionTitle">${HeaderCustomDlnaProfiles}</h2>
<a is="emby-linkbutton" href="#/dlnaprofile.html" class="fab submit" style="margin:0 0 0 1em"> <a is="emby-linkbutton" href="#/dashboard/dlna/profiles/edit" class="fab submit" style="margin:0 0 0 1em">
<span class="material-icons add" aria-hidden="true"></span> <span class="material-icons add" aria-hidden="true"></span>
</a> </a>
</div> </div>

Some files were not shown because too many files have changed in this diff Show more