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

17
package-lock.json generated
View file

@ -10760,6 +10760,23 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true "dev": true
}, },
"react-router": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz",
"integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==",
"requires": {
"history": "^5.2.0"
}
},
"react-router-dom": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz",
"integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==",
"requires": {
"history": "^5.2.0",
"react-router": "6.3.0"
}
},
"read-file-stdin": { "read-file-stdin": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/read-file-stdin/-/read-file-stdin-0.2.1.tgz", "resolved": "https://registry.npmjs.org/read-file-stdin/-/read-file-stdin-0.2.1.tgz",

View file

@ -96,6 +96,7 @@
"pdfjs-dist": "2.12.313", "pdfjs-dist": "2.12.313",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-router-dom": "6.3.0",
"resize-observer-polyfill": "1.5.1", "resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.0", "screenfull": "6.0.0",
"sortablejs": "1.14.0", "sortablejs": "1.14.0",

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 isBack: action === Action.Pop
}); });
} else { } 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); Events.on(apiClient, 'requestfail', this.onRequestFail);
}); });
ServerConnections.connect().then(result => { return ServerConnections.connect().then(result => {
this.firstConnectionResult = result; this.firstConnectionResult = result;
// Handle the initial route // 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 = { type LiveTVSearchResultsProps = {
serverId?: string; serverId?: string;
parentId?: string; parentId?: string | null;
collectionType?: string; collectionType?: string | null;
query?: string; query?: string;
} }

View file

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

View file

@ -22,7 +22,7 @@ const createSuggestionLink = ({ name, href }: { name: string, href: string }) =>
type SearchSuggestionsProps = { type SearchSuggestionsProps = {
serverId?: string; serverId?: string;
parentId?: string; parentId?: string | null;
} }
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ serverId = window.ApiClient.serverId(), parentId }: SearchSuggestionsProps) => { 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) { tryRestoreView(options, onViewChanging) {
if (options.cancel) { if (options.cancel) {
return Promise.reject({ cancelled: true }); return Promise.reject({ cancelled: true });

View file

@ -1,2 +0,0 @@
<div id="searchPage" data-role="page" class="page libraryPage allLibraryPage noSecondaryNavPage" data-title="${Search}" data-backbutton="true">
</div>

View file

@ -158,6 +158,7 @@
<div class="mainAnimatedPages skinBody"> <div class="mainAnimatedPages skinBody">
<div class="splashLogo"></div> <div class="splashLogo"></div>
</div> </div>
<div id="reactRoot"></div>
<div class="mainDrawerHandle"></div> <div class="mainDrawerHandle"></div>
</body> </body>
</html> </html>

24
src/routes/index.tsx Normal file
View file

@ -0,0 +1,24 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import ConnectionRequired from '../components/ConnectionRequired';
import SearchPage from './search';
const AppRoutes = () => (
<Routes>
<Route path='/'>
<Route
path='search.html'
element={
<ConnectionRequired>
<SearchPage />
</ConnectionRequired>
}
/>
{/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} />
</Route>
</Routes>
);
export default AppRoutes;

44
src/routes/search.tsx Normal file
View file

@ -0,0 +1,44 @@
import React, { FunctionComponent, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Page from '../components/Page';
import SearchFields from '../components/search/SearchFields';
import SearchResults from '../components/search/SearchResults';
import SearchSuggestions from '../components/search/SearchSuggestions';
import LiveTVSearchResults from '../components/search/LiveTVSearchResults';
import globalize from '../scripts/globalize';
const SearchPage: FunctionComponent = () => {
const [ query, setQuery ] = useState<string>();
const [ searchParams ] = useSearchParams();
return (
<Page
id='searchPage'
title={globalize.translate('Search')}
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
>
<SearchFields onSearch={setQuery} />
{!query &&
<SearchSuggestions
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
parentId={searchParams.get('parentId')}
/>
}
<SearchResults
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
parentId={searchParams.get('parentId')}
collectionType={searchParams.get('collectionType')}
query={query}
/>
<LiveTVSearchResults
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
parentId={searchParams.get('parentId')}
collectionType={searchParams.get('collectionType')}
query={query}
/>
</Page>
);
};
export default SearchPage;

View file

@ -308,12 +308,6 @@ import { appRouter } from '../components/appRouter';
type: 'home' type: 'home'
}); });
defineRoute({
alias: '/search.html',
path: 'search.html',
pageComponent: 'SearchPage'
});
defineRoute({ defineRoute({
alias: '/list.html', alias: '/list.html',
path: 'list.html', path: 'list.html',

View file

@ -7,6 +7,8 @@ import 'classlist.js';
import 'whatwg-fetch'; import 'whatwg-fetch';
import 'resize-observer-polyfill'; import 'resize-observer-polyfill';
import '../assets/css/site.scss'; import '../assets/css/site.scss';
import React from 'react';
import * as ReactDOM from 'react-dom';
import { Events } from 'jellyfin-apiclient'; import { Events } from 'jellyfin-apiclient';
import ServerConnections from '../components/ServerConnections'; import ServerConnections from '../components/ServerConnections';
import globalize from './globalize'; import globalize from './globalize';
@ -18,7 +20,7 @@ import { appHost } from '../components/apphost';
import { getPlugins } from './settings/webSettings'; import { getPlugins } from './settings/webSettings';
import { pluginManager } from '../components/pluginManager'; import { pluginManager } from '../components/pluginManager';
import packageManager from '../components/packageManager'; import packageManager from '../components/packageManager';
import { appRouter } from '../components/appRouter'; import { appRouter, history } from '../components/appRouter';
import '../elements/emby-button/emby-button'; import '../elements/emby-button/emby-button';
import './autoThemes'; import './autoThemes';
import './libraryMenu'; import './libraryMenu';
@ -40,6 +42,8 @@ import SyncPlayHtmlVideoPlayer from '../components/syncPlay/ui/players/HtmlVideo
import SyncPlayHtmlAudioPlayer from '../components/syncPlay/ui/players/HtmlAudioPlayer'; import SyncPlayHtmlAudioPlayer from '../components/syncPlay/ui/players/HtmlAudioPlayer';
import { currentSettings } from './settings/userSettings'; import { currentSettings } from './settings/userSettings';
import taskButton from './taskbutton'; import taskButton from './taskbutton';
import { HistoryRouter } from '../components/HistoryRouter.tsx';
import AppRoutes from '../routes/index.tsx';
function loadCoreDictionary() { function loadCoreDictionary() {
const languages = ['af', 'ar', 'be-by', 'bg-bg', 'bn_bd', 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en-gb', 'en-us', 'eo', 'es', 'es-419', 'es-ar', 'es_do', 'es-mx', 'et', 'fa', 'fi', 'fil', 'fr', 'fr-ca', 'gl', 'gsw', 'he', 'hi-in', 'hr', 'hu', 'id', 'it', 'ja', 'kk', 'ko', 'lt-lt', 'lv', 'mr', 'ms', 'nb', 'nl', 'nn', 'pl', 'pr', 'pt', 'pt-br', 'pt-pt', 'ro', 'ru', 'sk', 'sl-si', 'sq', 'sv', 'ta', 'th', 'tr', 'uk', 'ur_pk', 'vi', 'zh-cn', 'zh-hk', 'zh-tw']; const languages = ['af', 'ar', 'be-by', 'bg-bg', 'bn_bd', 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en-gb', 'en-us', 'eo', 'es', 'es-419', 'es-ar', 'es_do', 'es-mx', 'et', 'fa', 'fi', 'fil', 'fr', 'fr-ca', 'gl', 'gsw', 'he', 'hi-in', 'hr', 'hu', 'id', 'it', 'ja', 'kk', 'ko', 'lt-lt', 'lv', 'mr', 'ms', 'nb', 'nl', 'nn', 'pl', 'pr', 'pt', 'pt-br', 'pt-pt', 'ro', 'ru', 'sk', 'sl-si', 'sq', 'sv', 'ta', 'th', 'tr', 'uk', 'ur_pk', 'vi', 'zh-cn', 'zh-hk', 'zh-tw'];
@ -167,7 +171,14 @@ async function onAppReady() {
ServerConnections.currentApiClient()?.ensureWebSocket(); ServerConnections.currentApiClient()?.ensureWebSocket();
}); });
appRouter.start(); await appRouter.start();
ReactDOM.render(
<HistoryRouter history={history}>
<AppRoutes />
</HistoryRouter>,
document.getElementById('reactRoot')
);
if (!browser.tv && !browser.xboxOne && !browser.ps4) { if (!browser.tv && !browser.xboxOne && !browser.ps4) {
import('../components/nowPlayingBar/nowPlayingBar'); import('../components/nowPlayingBar/nowPlayingBar');

View file

@ -96,7 +96,7 @@ module.exports = {
}, },
{ {
test: /\.(js|jsx)$/, test: /\.(js|jsx)$/,
exclude: /node_modules[\\/](?!@uupaa[\\/]dynamic-import-polyfill|blurhash|date-fns|epubjs|flv.js|libarchive.js|marked|screenfull)/, exclude: /node_modules[\\/](?!@uupaa[\\/]dynamic-import-polyfill|blurhash|date-fns|epubjs|flv.js|libarchive.js|marked|react-router|screenfull)/,
use: [{ use: [{
loader: 'babel-loader' loader: 'babel-loader'
}] }]