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

Merge pull request #3706 from thornbill/react-router-3

Add react router and migrate search page
This commit is contained in:
Bill Thornton 2022-06-28 16:51:11 -04:00 committed by GitHub
commit c31e8968dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 407 additions and 60 deletions

View file

@ -0,0 +1,169 @@
import React, { FunctionComponent, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import alert from './alert';
import { appRouter } from './appRouter';
import loading from './loading/loading';
import ServerConnections from './ServerConnections';
import globalize from '../scripts/globalize';
enum BounceRoutes {
Home = '/home.html',
Login = '/login.html',
SelectServer = '/selectserver.html',
StartWizard = '/wizardstart.html'
}
// TODO: This should probably be in the SDK
enum ConnectionState {
SignedIn = 'SignedIn',
ServerSignIn = 'ServerSignIn',
ServerSelection = 'ServerSelection',
ServerUpdateNeeded = 'ServerUpdateNeeded'
}
type ConnectionRequiredProps = {
isAdminRequired?: boolean,
isUserRequired?: boolean
};
/**
* A component that ensures a server connection has been established.
* Additional parameters exist to verify a user or admin have authenticated.
* If a condition fails, this component will navigate to the appropriate page.
*/
const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
children,
isAdminRequired = false,
isUserRequired = true
}) => {
const navigate = useNavigate();
const [ isLoading, setIsLoading ] = useState(true);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bounce = async (connectionResponse: any) => {
switch (connectionResponse.State) {
case ConnectionState.SignedIn:
// Already logged in, bounce to the home page
console.debug('[ConnectionRequired] already logged in, redirecting to home');
navigate(BounceRoutes.Home);
return;
case ConnectionState.ServerSignIn:
// Bounce to the login page
console.debug('[ConnectionRequired] not logged in, redirecting to login page');
navigate(BounceRoutes.Login, {
state: {
serverid: connectionResponse.ApiClient.serverId()
}
});
return;
case ConnectionState.ServerSelection:
// Bounce to select server page
console.debug('[ConnectionRequired] redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
case ConnectionState.ServerUpdateNeeded:
// Show update needed message and bounce to select server page
try {
await alert({
text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'),
html: globalize.translate('ServerUpdateNeeded', '<a href="https://github.com/jellyfin/jellyfin">https://github.com/jellyfin/jellyfin</a>')
});
} catch (ex) {
console.warn('[ConnectionRequired] failed to show alert', ex);
}
console.debug('[ConnectionRequired] server update required, redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
}
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
};
const validateConnection = async () => {
// Check connection status on initial page load
const firstConnection = appRouter.firstConnectionResult;
appRouter.firstConnectionResult = null;
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
if (firstConnection.State === ConnectionState.ServerSignIn) {
// Verify the wizard is complete
try {
const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`);
if (!infoResponse.ok) {
throw new Error('Public system info request failed');
}
const systemInfo = await infoResponse.json();
if (!systemInfo?.StartupWizardCompleted) {
// Bounce to the wizard
console.info('[ConnectionRequired] startup wizard is not complete, redirecting there');
navigate(BounceRoutes.StartWizard);
return;
}
} catch (ex) {
console.error('[ConnectionRequired] checking wizard status failed', ex);
return;
}
}
// Bounce to the correct page in the login flow
bounce(firstConnection);
return;
}
// TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
// This case will need to be handled elsewhere before appRouter can be killed.
const client = ServerConnections.currentApiClient();
// If this is a user route, ensure a user is logged in
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
try {
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
bounce(await ServerConnections.connect());
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from user route', ex);
}
return;
}
// If this is an admin route, ensure the user has access
if (isAdminRequired) {
try {
const user = await client.getCurrentUser();
if (!user.Policy.IsAdministrator) {
console.warn('[ConnectionRequired] normal user attempted to access admin route');
bounce(await ServerConnections.connect());
return;
}
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from admin route', ex);
return;
}
}
setIsLoading(false);
};
loading.show();
validateConnection();
}, [ isAdminRequired, isUserRequired, navigate ]);
useEffect(() => {
if (!isLoading) {
loading.hide();
}
}, [ isLoading ]);
if (isLoading) {
return null;
}
return (
<>{children}</>
);
};
export default ConnectionRequired;

View file

@ -0,0 +1,48 @@
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<Update>({
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 (
<Router
basename={basename}
// eslint-disable-next-line react/no-children-prop
children={children}
location={{
...state.location,
// The original location does not get replaced with the normalized version, so we need to strip it here
pathname: normalizePath(state.location.pathname)
}}
navigationType={state.action}
navigator={history}
/>
);
}

69
src/components/Page.tsx Normal file
View file

@ -0,0 +1,69 @@
import React, { FunctionComponent, HTMLAttributes, useEffect, useRef } from 'react';
import viewManager from './viewManager/viewManager';
type PageProps = {
id: string, // id is required for libraryMenu
title?: string,
isBackButtonEnabled?: boolean,
isNowPlayingBarEnabled?: boolean,
isThemeMediaSupported?: boolean
};
/**
* Page component that handles hiding active non-react views, triggering the required events for
* navigation and appRouter state updates, and setting the correct classes and data attributes.
*/
const Page: FunctionComponent<PageProps & HTMLAttributes<HTMLDivElement>> = ({
children,
id,
className = '',
title,
isBackButtonEnabled = true,
isNowPlayingBarEnabled = true,
isThemeMediaSupported = false
}) => {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
// hide active non-react views
viewManager.hideView();
}, []);
useEffect(() => {
const event = {
bubbles: true,
cancelable: false,
detail: {
isRestored: false,
options: {
enableMediaControl: isNowPlayingBarEnabled,
supportsThemeMedia: isThemeMediaSupported
}
}
};
// viewbeforeshow - switches between the admin dashboard and standard themes
element.current?.dispatchEvent(new CustomEvent('viewbeforeshow', event));
// pagebeforeshow - hides tabs on tables pages in libraryMenu
element.current?.dispatchEvent(new CustomEvent('pagebeforeshow', event));
// viewshow - updates state of appRouter
element.current?.dispatchEvent(new CustomEvent('viewshow', event));
// pageshow - updates header/navigation in libraryMenu
element.current?.dispatchEvent(new CustomEvent('pageshow', event));
}, [ element, isNowPlayingBarEnabled, isThemeMediaSupported ]);
return (
<div
ref={element}
id={id}
data-role='page'
className={`page ${className}`}
data-title={title}
data-backbutton={`${isBackButtonEnabled}`}
>
{children}
</div>
);
};
export default Page;

View file

@ -122,7 +122,11 @@ class AppRouter {
isBack: action === Action.Pop
});
} else {
console.warn('[appRouter] "%s" route not found', normalizedPath, location);
console.info('[appRouter] "%s" route not found', normalizedPath, location);
this.currentRouteInfo = {
route: {},
path: normalizedPath + location.search
};
}
}
@ -139,7 +143,7 @@ class AppRouter {
Events.on(apiClient, 'requestfail', this.onRequestFail);
});
ServerConnections.connect().then(result => {
return ServerConnections.connect().then(result => {
this.firstConnectionResult = result;
// Handle the initial route

View file

@ -1,42 +0,0 @@
import React, { FunctionComponent, useState } from 'react';
import SearchFields from '../search/SearchFields';
import SearchResults from '../search/SearchResults';
import SearchSuggestions from '../search/SearchSuggestions';
import LiveTVSearchResults from '../search/LiveTVSearchResults';
type SearchProps = {
serverId?: string,
parentId?: string,
collectionType?: string
};
const SearchPage: FunctionComponent<SearchProps> = ({ serverId, parentId, collectionType }: SearchProps) => {
const [ query, setQuery ] = useState<string>();
return (
<>
<SearchFields onSearch={setQuery} />
{!query &&
<SearchSuggestions
serverId={serverId || window.ApiClient.serverId()}
parentId={parentId}
/>
}
<SearchResults
serverId={serverId || window.ApiClient.serverId()}
parentId={parentId}
collectionType={collectionType}
query={query}
/>
<LiveTVSearchResults
serverId={serverId || window.ApiClient.serverId()}
parentId={parentId}
collectionType={collectionType}
query={query}
/>
</>
);
};
export default SearchPage;

View file

@ -21,8 +21,8 @@ const CARD_OPTIONS = {
type LiveTVSearchResultsProps = {
serverId?: string;
parentId?: string;
collectionType?: string;
parentId?: string | null;
collectionType?: string | null;
query?: string;
}

View file

@ -9,8 +9,8 @@ import SearchResultsRow from './SearchResultsRow';
type SearchResultsProps = {
serverId?: string;
parentId?: string;
collectionType?: string;
parentId?: string | null;
collectionType?: string | null;
query?: string;
}

View file

@ -22,7 +22,7 @@ const createSuggestionLink = ({ name, href }: { name: string, href: string }) =>
type SearchSuggestionsProps = {
serverId?: string;
parentId?: string;
parentId?: string | null;
}
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ serverId = window.ApiClient.serverId(), parentId }: SearchSuggestionsProps) => {

View file

@ -147,6 +147,15 @@ class ViewManager {
});
}
hideView() {
if (currentView) {
dispatchViewEvent(currentView, null, 'viewbeforehide');
dispatchViewEvent(currentView, null, 'viewhide');
currentView.classList.add('hide');
currentView = null;
}
}
tryRestoreView(options, onViewChanging) {
if (options.cancel) {
return Promise.reject({ cancelled: true });