diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 05b512b8aea..7b5e4aeccd6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -71,6 +71,7 @@ - [scampower3](https://github.com/scampower3) - [LittleBigOwI] (https://github.com/LittleBigOwI/) - [Nate G](https://github.com/GGProGaming) + - [Grady Hallenbeck](https://github.com/grhallenbeck) # Emby Contributors diff --git a/src/RootApp.tsx b/src/RootApp.tsx index cc10ca7baa5..23ff1bb2303 100644 --- a/src/RootApp.tsx +++ b/src/RootApp.tsx @@ -4,59 +4,35 @@ import { History } from '@remix-run/router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { DASHBOARD_APP_PATHS } from 'apps/dashboard/App'; -import AppHeader from 'components/AppHeader'; -import Backdrop from 'components/Backdrop'; -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 StableApp = loadable(() => import('./apps/stable/App')); +const StableAppRouter = loadable(() => import('./apps/stable/AppRouter')); +const RootAppRouter = loadable(() => import('./RootAppRouter')); const queryClient = new QueryClient(); -const RootAppLayout = () => { +const RootApp = ({ history }: Readonly<{ history: History }>) => { 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 ( - <> - - - - { - isExperimentalLayout ? - : - - } - - - + + + + + {isExperimentalLayout ? + : + + } + + + + + ); }; -const RootApp = ({ history }: { history: History }) => ( - - - - - - - - - - - - -); - export default RootApp; diff --git a/src/RootAppRouter.tsx b/src/RootAppRouter.tsx new file mode 100644 index 00000000000..9a15264db00 --- /dev/null +++ b/src/RootAppRouter.tsx @@ -0,0 +1,41 @@ + +import { History } from '@remix-run/router'; +import React from 'react'; +import { + RouterProvider, + createHashRouter, + Outlet +} from 'react-router-dom'; + +import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes'; +import AppHeader from 'components/AppHeader'; +import Backdrop from 'components/Backdrop'; +import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync'; +import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes'; + +const router = createHashRouter([ + { + element: , + children: [ + ...EXPERIMENTAL_APP_ROUTES, + ...DASHBOARD_APP_ROUTES + ] + } +]); + +export default function RootAppRouter({ history }: Readonly<{ history: History}>) { + useLegacyRouterSync({ router, history }); + + return ; +} + +function RootAppLayout() { + return ( + <> + + + + + + ); +} diff --git a/src/apps/dashboard/App.tsx b/src/apps/dashboard/App.tsx deleted file mode 100644 index 640a60d1f80..00000000000 --- a/src/apps/dashboard/App.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 = () => ( - - }> - }> - - {ASYNC_ADMIN_ROUTES.map(toDashboardAsyncPageRoute)} - {LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)} - - - {/* NOTE: The metadata editor might deserve a dedicated app in the future */} - {toViewManagerPageRoute({ - path: DASHBOARD_APP_PATHS.MetadataManager, - pageProps: { - controller: 'edititemmetadata', - view: 'edititemmetadata.html' - } - })} - - - } /> - - - - {/* Suppress warnings for unhandled routes */} - - - {/* Redirects for old paths */} - {REDIRECTS.map(toRedirectRoute)} - -); - -export default DashboardApp; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 09d40de0e83..9abd6b75e32 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -1,12 +1,12 @@ -import type { AsyncRoute } from 'components/router/AsyncRoute'; +import { AsyncRouteType, 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' } + { path: 'activity', type: AsyncRouteType.Dashboard }, + { path: 'notifications', type: AsyncRouteType.Dashboard }, + { path: 'users', type: AsyncRouteType.Dashboard }, + { path: 'users/access', type: AsyncRouteType.Dashboard }, + { path: 'users/add', type: AsyncRouteType.Dashboard }, + { path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard }, + { path: 'users/password', type: AsyncRouteType.Dashboard }, + { path: 'users/profile', type: AsyncRouteType.Dashboard } ]; diff --git a/src/apps/dashboard/routes/routes.tsx b/src/apps/dashboard/routes/routes.tsx new file mode 100644 index 00000000000..7bbeb3fc4e0 --- /dev/null +++ b/src/apps/dashboard/routes/routes.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { RouteObject } from 'react-router-dom'; +import AppLayout from '../AppLayout'; +import ConnectionRequired from 'components/ConnectionRequired'; +import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes'; +import { toAsyncPageRoute } from 'components/router/AsyncRoute'; +import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; +import { LEGACY_ADMIN_ROUTES } from './_legacyRoutes'; +import ServerContentPage from 'components/ServerContentPage'; + +export const DASHBOARD_APP_PATHS = { + Dashboard: 'dashboard', + MetadataManager: 'metadata', + PluginConfig: 'configurationpage' +}; + +export const DASHBOARD_APP_ROUTES: RouteObject[] = [ + { + element: , + children: [ + { + element: , + children: [ + { + path: DASHBOARD_APP_PATHS.Dashboard, + children: [ + ...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute), + ...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute) + ] + }, + + /* NOTE: The metadata editor might deserve a dedicated app in the future */ + toViewManagerPageRoute({ + path: DASHBOARD_APP_PATHS.MetadataManager, + pageProps: { + controller: 'edititemmetadata', + view: 'edititemmetadata.html' + } + }), + + { + path: DASHBOARD_APP_PATHS.PluginConfig, + element: + } + ] + } + ] + } +]; diff --git a/src/apps/experimental/App.tsx b/src/apps/experimental/App.tsx deleted file mode 100644 index b17e9054ee6..00000000000 --- a/src/apps/experimental/App.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -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 { toAsyncPageRoute } from 'components/router/AsyncRoute'; -import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; -import { toRedirectRoute } from 'components/router/Redirect'; - -import AppLayout from './AppLayout'; -import { ASYNC_USER_ROUTES } from './routes/asyncRoutes'; -import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes'; - -const ExperimentalApp = () => { - return ( - - }> - {/* User routes */} - }> - {ASYNC_USER_ROUTES.map(toAsyncPageRoute)} - {LEGACY_USER_ROUTES.map(toViewManagerPageRoute)} - - - {/* Public routes */} - }> - } /> - - {LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)} - - - - {/* Redirects for old paths */} - {REDIRECTS.map(toRedirectRoute)} - - {/* Ignore dashboard routes */} - {Object.entries(DASHBOARD_APP_PATHS).map(([ key, path ]) => ( - - ))} - - ); -}; - -export default ExperimentalApp; diff --git a/src/apps/experimental/routes/routes.tsx b/src/apps/experimental/routes/routes.tsx new file mode 100644 index 00000000000..3156f5f0e33 --- /dev/null +++ b/src/apps/experimental/routes/routes.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { RouteObject, redirect } from 'react-router-dom'; + +import { REDIRECTS } from 'apps/dashboard/routes/_redirects'; +import { DASHBOARD_APP_PATHS } from 'apps/dashboard/routes/routes'; +import ConnectionRequired from 'components/ConnectionRequired'; +import { toAsyncPageRoute } from 'components/router/AsyncRoute'; +import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; +import { toRedirectRoute } from 'components/router/Redirect'; +import AppLayout from '../AppLayout'; +import { ASYNC_USER_ROUTES } from './asyncRoutes'; +import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './legacyRoutes'; + +export const EXPERIMENTAL_APP_ROUTES: RouteObject[] = [ + { + path: '/*', + element: , + children: [ + { + /* User routes: Any child route of this layout is authenticated */ + element: , + children: [ + ...ASYNC_USER_ROUTES.map(toAsyncPageRoute), + ...LEGACY_USER_ROUTES.map(toViewManagerPageRoute) + ] + }, + + /* Public routes */ + { index: true, loader: () => redirect('/home.html') }, + ...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute) + ] + }, + + /* Redirects for old paths */ + ...REDIRECTS.map(toRedirectRoute), + + /* Ignore dashboard routes */ + ...Object.entries(DASHBOARD_APP_PATHS).map(([, path]) => ({ + path: `/${path}/*`, + element: null + })) +]; diff --git a/src/apps/stable/App.tsx b/src/apps/stable/App.tsx deleted file mode 100644 index 9b0adbab9cc..00000000000 --- a/src/apps/stable/App.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; - -import { DASHBOARD_APP_PATHS } from 'apps/dashboard/App'; -import AppBody from 'components/AppBody'; -import ConnectionRequired from 'components/ConnectionRequired'; -import { toAsyncPageRoute } from 'components/router/AsyncRoute'; -import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; -import { toRedirectRoute } from 'components/router/Redirect'; - -import { ASYNC_USER_ROUTES } from './routes/asyncRoutes'; -import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes'; -import { REDIRECTS } from './routes/_redirects'; - -const Layout = () => ( - - - -); - -const StableApp = () => ( - - }> - {/* User routes */} - }> - {ASYNC_USER_ROUTES.map(toAsyncPageRoute)} - {LEGACY_USER_ROUTES.map(toViewManagerPageRoute)} - - - {/* Public routes */} - }> - } /> - - {LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)} - - - {/* Suppress warnings for unhandled routes */} - - - - {/* Redirects for old paths */} - {REDIRECTS.map(toRedirectRoute)} - - {/* Ignore dashboard routes */} - {Object.entries(DASHBOARD_APP_PATHS).map(([ key, path ]) => ( - - ))} - -); - -export default StableApp; diff --git a/src/apps/stable/AppLayout.tsx b/src/apps/stable/AppLayout.tsx new file mode 100644 index 00000000000..bac962784c8 --- /dev/null +++ b/src/apps/stable/AppLayout.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; + +import AppBody from 'components/AppBody'; +import { DASHBOARD_APP_PATHS } from 'apps/dashboard/routes/routes'; +import Backdrop from 'components/Backdrop'; +import AppHeader from 'components/AppHeader'; + +export default function AppLayout() { + const location = useLocation(); + const isNewLayoutPath = Object.values(DASHBOARD_APP_PATHS) + .some(path => location.pathname.startsWith(`/${path}`)); + + return ( + <> + + + + + + + + ); +} diff --git a/src/apps/stable/AppRouter.tsx b/src/apps/stable/AppRouter.tsx new file mode 100644 index 00000000000..761ade98a86 --- /dev/null +++ b/src/apps/stable/AppRouter.tsx @@ -0,0 +1,18 @@ +import { History } from '@remix-run/router'; +import React from 'react'; +import { RouterProvider, createHashRouter } from 'react-router-dom'; + +import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes'; +import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync'; +import { STABLE_APP_ROUTES } from './routes/routes'; + +const router = createHashRouter([ + ...STABLE_APP_ROUTES, + ...DASHBOARD_APP_ROUTES +]); + +export default function StableAppRouter({ history }: Readonly<{ history: History }>) { + useLegacyRouterSync({ router, history }); + + return ; +} diff --git a/src/apps/stable/routes/routes.tsx b/src/apps/stable/routes/routes.tsx new file mode 100644 index 00000000000..77f936276f6 --- /dev/null +++ b/src/apps/stable/routes/routes.tsx @@ -0,0 +1,46 @@ +import { RouteObject, redirect } from 'react-router-dom'; +import React from 'react'; + +import { DASHBOARD_APP_PATHS } from 'apps/dashboard/routes/routes'; +import ConnectionRequired from 'components/ConnectionRequired'; +import { toAsyncPageRoute } from 'components/router/AsyncRoute'; +import { toViewManagerPageRoute } from 'components/router/LegacyRoute'; +import { toRedirectRoute } from 'components/router/Redirect'; +import AppLayout from '../AppLayout'; +import { REDIRECTS } from './_redirects'; +import { ASYNC_USER_ROUTES } from './asyncRoutes'; +import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './legacyRoutes'; + +export const STABLE_APP_ROUTES: RouteObject[] = [ + { + path: '/*', + element: , + children: [ + { + /* User routes */ + element: , + children: [ + ...ASYNC_USER_ROUTES.map(toAsyncPageRoute), + ...LEGACY_USER_ROUTES.map(toViewManagerPageRoute) + ] + }, + + /* Public routes */ + { index: true, loader: () => redirect('/home.html') }, + ...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute), + + /* Suppress warnings for unhandled routes */ + { path: '*', element: null } + ] + }, + + /* Redirects for old paths */ + ...REDIRECTS.map(toRedirectRoute), + + /* Ignore dashboard routes */ + ...Object.entries(DASHBOARD_APP_PATHS).map(([, path]) => ({ + path: `/${path}/*`, + element: null + })) + +]; diff --git a/src/components/router/AsyncRoute.tsx b/src/components/router/AsyncRoute.tsx index 031e5700eaf..19457851d31 100644 --- a/src/components/router/AsyncRoute.tsx +++ b/src/components/router/AsyncRoute.tsx @@ -1,10 +1,10 @@ import loadable, { LoadableComponent } from '@loadable/component'; import React from 'react'; -import { Route } from 'react-router-dom'; export enum AsyncRouteType { Stable, - Experimental + Experimental, + Dashboard, } export interface AsyncRoute { @@ -26,6 +26,11 @@ export interface AsyncPageProps { page: string } +const DashboardAsyncPage = loadable( + (props: { page: string }) => import(/* webpackChunkName: "[request]" */ `../../apps/dashboard/routes/${props.page}`), + { cacheKey: (props: AsyncPageProps) => props.page } +); + const ExperimentalAsyncPage = loadable( (props: { page: string }) => import(/* webpackChunkName: "[request]" */ `../../apps/experimental/routes/${props.page}`), { cacheKey: (props: AsyncPageProps) => props.page } @@ -36,19 +41,24 @@ const StableAsyncPage = loadable( { cacheKey: (props: AsyncPageProps) => props.page } ); -export const toAsyncPageRoute = ({ path, page, element, type = AsyncRouteType.Stable }: AsyncRoute) => { - const Element = element - || ( - type === AsyncRouteType.Experimental ? - ExperimentalAsyncPage : - StableAsyncPage - ); - - return ( - } - /> - ); -}; +export function toAsyncPageRoute({ path, page, element, type = AsyncRouteType.Stable }: AsyncRoute) { + let Element = element; + if (!Element) { + switch (type) { + case AsyncRouteType.Dashboard: + Element = DashboardAsyncPage; + break; + case AsyncRouteType.Experimental: + Element = ExperimentalAsyncPage; + break; + case AsyncRouteType.Stable: + default: + Element = StableAsyncPage; + } + } + + return { + path, + element: + }; +} diff --git a/src/components/router/HistoryRouter.tsx b/src/components/router/HistoryRouter.tsx deleted file mode 100644 index 21e1efe0fed..00000000000 --- a/src/components/router/HistoryRouter.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { useLayoutEffect } from 'react'; -import { HistoryRouterProps, Router } from 'react-router-dom'; -import { Update } from 'history'; - -/** Strips leading "!" from paths */ -const normalizePath = (pathname: string) => pathname.replace(/^!/, ''); - -/** - * A slightly customized version of the HistoryRouter from react-router-dom. - * We need to use HistoryRouter to have a shared history state between react-router and appRouter, but it does not seem - * to be properly exported in the upstream package. - * We also needed some customizations to handle #! routes. - * Refs: https://github.com/remix-run/react-router/blob/v6.3.0/packages/react-router-dom/index.tsx#L222 - */ -export function HistoryRouter({ basename, children, history }: HistoryRouterProps) { - const [state, setState] = React.useState({ - action: history.action, - location: history.location - }); - - useLayoutEffect(() => { - const onHistoryChange = (update: Update) => { - if (update.location.pathname.startsWith('!')) { - // When the location changes, we need to check for #! paths and replace the location with the "!" stripped - history.replace(normalizePath(update.location.pathname), update.location.state); - } else { - setState(update); - } - }; - - history.listen(onHistoryChange); - }, [ history ]); - - return ( - - ); -} diff --git a/src/components/router/LegacyRoute.tsx b/src/components/router/LegacyRoute.tsx index bba780a513a..983c4d84cfb 100644 --- a/src/components/router/LegacyRoute.tsx +++ b/src/components/router/LegacyRoute.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Route } from 'react-router-dom'; import ViewManagerPage, { ViewManagerPageProps } from '../viewManager/ViewManagerPage'; @@ -9,13 +8,8 @@ export interface LegacyRoute { } export function toViewManagerPageRoute(route: LegacyRoute) { - return ( - - } - /> - ); + return { + path: route.path, + element: + }; } diff --git a/src/components/router/Redirect.tsx b/src/components/router/Redirect.tsx index 7354f16c554..d68ecdea79f 100644 --- a/src/components/router/Redirect.tsx +++ b/src/components/router/Redirect.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate, Route, useLocation } from 'react-router-dom'; +import { Navigate, RouteObject, useLocation } from 'react-router-dom'; export interface Redirect { from: string @@ -17,12 +17,9 @@ const RedirectWithSearch = ({ to }: { to: string }) => { ); }; -export function toRedirectRoute({ from, to }: Redirect) { - return ( - } - /> - ); +export function toRedirectRoute({ from, to }: Redirect): RouteObject { + return { + path: from, + element: + }; } diff --git a/src/hooks/useLegacyRouterSync.ts b/src/hooks/useLegacyRouterSync.ts new file mode 100644 index 00000000000..fe28df09059 --- /dev/null +++ b/src/hooks/useLegacyRouterSync.ts @@ -0,0 +1,63 @@ +import { Update } from 'history'; +import { useLayoutEffect, useState } from 'react'; +import type { History, Router } from '@remix-run/router'; + +const normalizePath = (pathname: string) => pathname.replace(/^!/, ''); + +interface UseLegacyRouterSyncProps { + router: Router; + history: History; +} +export function useLegacyRouterSync({ router, history }: UseLegacyRouterSyncProps) { + const [routerLocation, setRouterLocation] = useState(router.state.location); + + useLayoutEffect(() => { + const onHistoryChange = async (update: Update) => { + const isSynced = router.createHref(router.state.location) === router.createHref(update.location); + + /** + * Some legacy codepaths may still use the `#!` routing scheme which is unsupported with the React routing + * implementation, so we need to remove the leading `!` from the pathname. React Router already removes the + * hash for us. + */ + if (update.location.pathname.startsWith('!')) { + history.replace(normalizePath(update.location.pathname), update.location.state); + } else if (!isSynced) { + await router.navigate(update.location, { replace: true }); + } + }; + + const unlisten = history.listen(onHistoryChange); + + return () => { + unlisten(); + }; + }, [history, router]); + + /** + * Because the router subscription needs to be in a zero-dependencies effect, syncing changes to the router back to + * the legacy history API needs to be in a separate effect. This should run any time the router location changes. + */ + useLayoutEffect(() => { + const isSynced = router.createHref(routerLocation) === router.createHref(history.location); + if (!isSynced) { + history.replace(routerLocation); + } + }, [history, router, routerLocation]); + + /** + * We want to use an effect with no dependencies here when we set up the router subscription to ensure that we only + * subscribe to the router state once. The router doesn't provide a way to remove subscribers, so we need to be + * careful to not create multiple subscribers. + */ + useLayoutEffect(() => { + router.subscribe((newState) => { + setRouterLocation((prevLocation) => { + if (newState.location !== prevLocation) { + return newState.location; + } + return prevLocation; + }); + }); + }); +}