mirror of
https://github.com/Radarr/Radarr
synced 2025-12-06 08:28:50 +01:00
Convert Page components to TypeScript
(cherry picked from commit f35a27449d253260ba9c9fae28909cec8a87b4fe)
This commit is contained in:
parent
7fdaf41325
commit
937557e214
68 changed files with 2391 additions and 2713 deletions
|
|
@ -14,7 +14,6 @@ module.exports = (env) => {
|
||||||
const srcFolder = path.join(frontendFolder, 'src');
|
const srcFolder = path.join(frontendFolder, 'src');
|
||||||
const isProduction = !!env.production;
|
const isProduction = !!env.production;
|
||||||
const isProfiling = isProduction && !!env.profile;
|
const isProfiling = isProduction && !!env.profile;
|
||||||
const inlineWebWorkers = 'no-fallback';
|
|
||||||
|
|
||||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||||
|
|
||||||
|
|
@ -160,16 +159,6 @@ module.exports = (env) => {
|
||||||
|
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
|
||||||
test: /\.worker\.js$/,
|
|
||||||
use: {
|
|
||||||
loader: 'worker-loader',
|
|
||||||
options: {
|
|
||||||
filename: '[name].js',
|
|
||||||
inline: inlineWebWorkers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
test: [/\.jsx?$/, /\.tsx?$/],
|
test: [/\.jsx?$/, /\.tsx?$/],
|
||||||
exclude: /(node_modules|JsLibraries)/,
|
exclude: /(node_modules|JsLibraries)/,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import React from 'react';
|
||||||
import DocumentTitle from 'react-document-title';
|
import DocumentTitle from 'react-document-title';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import PageConnector from 'Components/Page/PageConnector';
|
import Page from 'Components/Page/Page';
|
||||||
import ApplyTheme from './ApplyTheme';
|
import ApplyTheme from './ApplyTheme';
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
|
|
||||||
|
|
@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ConnectedRouter history={history}>
|
<ConnectedRouter history={history}>
|
||||||
<ApplyTheme />
|
<ApplyTheme />
|
||||||
<PageConnector>
|
<Page>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</PageConnector>
|
</Page>
|
||||||
</ConnectedRouter>
|
</ConnectedRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { Error } from './AppSectionState';
|
||||||
import BlocklistAppState from './BlocklistAppState';
|
import BlocklistAppState from './BlocklistAppState';
|
||||||
import CalendarAppState from './CalendarAppState';
|
import CalendarAppState from './CalendarAppState';
|
||||||
import CaptchaAppState from './CaptchaAppState';
|
import CaptchaAppState from './CaptchaAppState';
|
||||||
import CommandAppState from './CommandAppState';
|
import CommandAppState from './CommandAppState';
|
||||||
|
import CustomFiltersAppState from './CustomFiltersAppState';
|
||||||
import ExtraFilesAppState from './ExtraFilesAppState';
|
import ExtraFilesAppState from './ExtraFilesAppState';
|
||||||
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
|
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
|
||||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||||
|
|
@ -55,7 +57,9 @@ export interface CustomFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSectionState {
|
export interface AppSectionState {
|
||||||
|
isUpdated: boolean;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
isDisconnected: boolean;
|
||||||
isReconnecting: boolean;
|
isReconnecting: boolean;
|
||||||
isSidebarVisible: boolean;
|
isSidebarVisible: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
|
|
@ -65,6 +69,10 @@ export interface AppSectionState {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
translations: {
|
||||||
|
error?: Error;
|
||||||
|
isPopulated: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
|
|
@ -73,6 +81,7 @@ interface AppState {
|
||||||
calendar: CalendarAppState;
|
calendar: CalendarAppState;
|
||||||
captcha: CaptchaAppState;
|
captcha: CaptchaAppState;
|
||||||
commands: CommandAppState;
|
commands: CommandAppState;
|
||||||
|
customFilters: CustomFiltersAppState;
|
||||||
extraFiles: ExtraFilesAppState;
|
extraFiles: ExtraFilesAppState;
|
||||||
history: HistoryAppState;
|
history: HistoryAppState;
|
||||||
interactiveImport: InteractiveImportAppState;
|
interactiveImport: InteractiveImportAppState;
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import keyboardShortcuts from 'Components/keyboardShortcuts';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
|
||||||
import Modal from 'Components/Modal/Modal';
|
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
|
||||||
|
|
||||||
function ConfirmModal(props) {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
kind,
|
|
||||||
size,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmLabel,
|
|
||||||
cancelLabel,
|
|
||||||
hideCancelButton,
|
|
||||||
isSpinning,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
bindShortcut,
|
|
||||||
unbindShortcut
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
bindShortcut('enter', onConfirm);
|
|
||||||
|
|
||||||
return () => unbindShortcut('enter', onConfirm);
|
|
||||||
}
|
|
||||||
}, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
size={size}
|
|
||||||
onModalClose={onCancel}
|
|
||||||
>
|
|
||||||
<ModalContent onModalClose={onCancel}>
|
|
||||||
<ModalHeader>{title}</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
|
||||||
{message}
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
{
|
|
||||||
!hideCancelButton &&
|
|
||||||
<Button
|
|
||||||
kind={kinds.DEFAULT}
|
|
||||||
onPress={onCancel}
|
|
||||||
>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<SpinnerButton
|
|
||||||
autoFocus={true}
|
|
||||||
kind={kind}
|
|
||||||
isSpinning={isSpinning}
|
|
||||||
onPress={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmLabel}
|
|
||||||
</SpinnerButton>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmModal.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
kind: PropTypes.oneOf(kinds.all),
|
|
||||||
size: PropTypes.oneOf(sizes.all),
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
|
||||||
confirmLabel: PropTypes.string,
|
|
||||||
cancelLabel: PropTypes.string,
|
|
||||||
hideCancelButton: PropTypes.bool,
|
|
||||||
isSpinning: PropTypes.bool.isRequired,
|
|
||||||
onConfirm: PropTypes.func.isRequired,
|
|
||||||
onCancel: PropTypes.func.isRequired,
|
|
||||||
bindShortcut: PropTypes.func.isRequired,
|
|
||||||
unbindShortcut: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
ConfirmModal.defaultProps = {
|
|
||||||
kind: kinds.PRIMARY,
|
|
||||||
size: sizes.MEDIUM,
|
|
||||||
confirmLabel: 'OK',
|
|
||||||
cancelLabel: 'Cancel',
|
|
||||||
isSpinning: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default keyboardShortcuts(ConfirmModal);
|
|
||||||
79
frontend/src/Components/Modal/ConfirmModal.tsx
Normal file
79
frontend/src/Components/Modal/ConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import { Size } from 'Helpers/Props/sizes';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
className?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
kind?: Kind;
|
||||||
|
size?: Size;
|
||||||
|
title: string;
|
||||||
|
message: React.ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
hideCancelButton?: boolean;
|
||||||
|
isSpinning?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
kind = 'primary',
|
||||||
|
size = 'medium',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'OK',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
hideCancelButton,
|
||||||
|
isSpinning = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
bindShortcut('acceptConfirmModal', onConfirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => unbindShortcut('acceptConfirmModal');
|
||||||
|
}, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} size={size} onModalClose={onCancel}>
|
||||||
|
<ModalContent onModalClose={onCancel}>
|
||||||
|
<ModalHeader>{title}</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>{message}</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
{!hideCancelButton && (
|
||||||
|
<Button kind="default" onPress={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SpinnerButton
|
||||||
|
autoFocus={true}
|
||||||
|
kind={kind}
|
||||||
|
isSpinning={isSpinning}
|
||||||
|
onPress={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</SpinnerButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmModal;
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
|
||||||
import styles from './ErrorPage.css';
|
|
||||||
|
|
||||||
function ErrorPage(props) {
|
|
||||||
const {
|
|
||||||
version,
|
|
||||||
isLocalStorageSupported,
|
|
||||||
translationsError,
|
|
||||||
moviesError,
|
|
||||||
customFiltersError,
|
|
||||||
tagsError,
|
|
||||||
qualityProfilesError,
|
|
||||||
languagesError,
|
|
||||||
uiSettingsError,
|
|
||||||
systemStatusError
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
let errorMessage = 'Failed to load Radarr';
|
|
||||||
|
|
||||||
if (!isLocalStorageSupported) {
|
|
||||||
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
|
|
||||||
} else if (translationsError) {
|
|
||||||
errorMessage = getErrorMessage(translationsError, 'Failed to load translations from API');
|
|
||||||
} else if (moviesError) {
|
|
||||||
errorMessage = getErrorMessage(moviesError, 'Failed to load movie from API');
|
|
||||||
} else if (customFiltersError) {
|
|
||||||
errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API');
|
|
||||||
} else if (tagsError) {
|
|
||||||
errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
|
|
||||||
} else if (qualityProfilesError) {
|
|
||||||
errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
|
|
||||||
} else if (languagesError) {
|
|
||||||
errorMessage = getErrorMessage(languagesError, 'Failed to load languages from API');
|
|
||||||
} else if (uiSettingsError) {
|
|
||||||
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
|
|
||||||
} else if (systemStatusError) {
|
|
||||||
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load system status from API');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.page}>
|
|
||||||
<div className={styles.errorMessage}>
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.version}>
|
|
||||||
Version {version}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ErrorPage.propTypes = {
|
|
||||||
version: PropTypes.string.isRequired,
|
|
||||||
isLocalStorageSupported: PropTypes.bool.isRequired,
|
|
||||||
translationsError: PropTypes.object,
|
|
||||||
moviesError: PropTypes.object,
|
|
||||||
customFiltersError: PropTypes.object,
|
|
||||||
tagsError: PropTypes.object,
|
|
||||||
qualityProfilesError: PropTypes.object,
|
|
||||||
languagesError: PropTypes.object,
|
|
||||||
uiSettingsError: PropTypes.object,
|
|
||||||
systemStatusError: PropTypes.object
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ErrorPage;
|
|
||||||
86
frontend/src/Components/Page/ErrorPage.tsx
Normal file
86
frontend/src/Components/Page/ErrorPage.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Error } from 'App/State/AppSectionState';
|
||||||
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
|
import styles from './ErrorPage.css';
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
version: string;
|
||||||
|
isLocalStorageSupported: boolean;
|
||||||
|
translationsError?: Error;
|
||||||
|
moviesError?: Error;
|
||||||
|
customFiltersError?: Error;
|
||||||
|
tagsError?: Error;
|
||||||
|
qualityProfilesError?: Error;
|
||||||
|
languagesError?: Error;
|
||||||
|
uiSettingsError?: Error;
|
||||||
|
systemStatusError?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorPage(props: ErrorPageProps) {
|
||||||
|
const {
|
||||||
|
version,
|
||||||
|
isLocalStorageSupported,
|
||||||
|
translationsError,
|
||||||
|
moviesError,
|
||||||
|
customFiltersError,
|
||||||
|
tagsError,
|
||||||
|
qualityProfilesError,
|
||||||
|
languagesError,
|
||||||
|
uiSettingsError,
|
||||||
|
systemStatusError,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
let errorMessage = 'Failed to load Radarr';
|
||||||
|
|
||||||
|
if (!isLocalStorageSupported) {
|
||||||
|
errorMessage =
|
||||||
|
'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
|
||||||
|
} else if (translationsError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
translationsError,
|
||||||
|
'Failed to load translations from API'
|
||||||
|
);
|
||||||
|
} else if (moviesError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
moviesError,
|
||||||
|
'Failed to load movie from API'
|
||||||
|
);
|
||||||
|
} else if (customFiltersError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
customFiltersError,
|
||||||
|
'Failed to load custom filters from API'
|
||||||
|
);
|
||||||
|
} else if (tagsError) {
|
||||||
|
errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
|
||||||
|
} else if (qualityProfilesError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
qualityProfilesError,
|
||||||
|
'Failed to load quality profiles from API'
|
||||||
|
);
|
||||||
|
} else if (languagesError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
languagesError,
|
||||||
|
'Failed to load languages from API'
|
||||||
|
);
|
||||||
|
} else if (uiSettingsError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
uiSettingsError,
|
||||||
|
'Failed to load UI settings from API'
|
||||||
|
);
|
||||||
|
} else if (systemStatusError) {
|
||||||
|
errorMessage = getErrorMessage(
|
||||||
|
uiSettingsError,
|
||||||
|
'Failed to load system status from API'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div>{errorMessage}</div>
|
||||||
|
|
||||||
|
<div className={styles.version}>Version {version}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorPage;
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Modal from 'Components/Modal/Modal';
|
|
||||||
import { sizes } from 'Helpers/Props';
|
|
||||||
import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector';
|
|
||||||
|
|
||||||
function KeyboardShortcutsModal(props) {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
onModalClose
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
size={sizes.SMALL}
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
>
|
|
||||||
<KeyboardShortcutsModalContentConnector
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyboardShortcutsModal.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KeyboardShortcutsModal;
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
|
||||||
|
|
||||||
|
interface KeyboardShortcutsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyboardShortcutsModal(props: KeyboardShortcutsModalProps) {
|
||||||
|
const { isOpen, onModalClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} size={sizes.SMALL} onModalClose={onModalClose}>
|
||||||
|
<KeyboardShortcutsModalContent onModalClose={onModalClose} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KeyboardShortcutsModal;
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shortcuts } from 'Components/keyboardShortcuts';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Shortcut, shortcuts } from 'Components/keyboardShortcuts';
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './KeyboardShortcutsModalContent.css';
|
import styles from './KeyboardShortcutsModalContent.css';
|
||||||
|
|
||||||
function getShortcuts() {
|
function getShortcuts() {
|
||||||
const allShortcuts = [];
|
const allShortcuts: Shortcut[] = [];
|
||||||
|
|
||||||
Object.keys(shortcuts).forEach((key) => {
|
Object.keys(shortcuts).forEach((key) => {
|
||||||
allShortcuts.push(shortcuts[key]);
|
allShortcuts.push(shortcuts[key]);
|
||||||
|
|
@ -19,8 +20,8 @@ function getShortcuts() {
|
||||||
return allShortcuts;
|
return allShortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getShortcutKey(combo, isOsx) {
|
function getShortcutKey(combo: string, isOsx: boolean) {
|
||||||
const comboMatch = combo.match(/(.+?)\+(.*)/);
|
const comboMatch = combo.match(/(.+?)\+(.)/);
|
||||||
|
|
||||||
if (!comboMatch) {
|
if (!comboMatch) {
|
||||||
return combo;
|
return combo;
|
||||||
|
|
@ -45,55 +46,39 @@ function getShortcutKey(combo, isOsx) {
|
||||||
return `${osModifier} + ${key}`;
|
return `${osModifier} + ${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyboardShortcutsModalContent(props) {
|
interface KeyboardShortcutsModalContentProps {
|
||||||
const {
|
onModalClose: () => void;
|
||||||
isOsx,
|
}
|
||||||
onModalClose
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
|
function KeyboardShortcutsModalContent({
|
||||||
|
onModalClose,
|
||||||
|
}: KeyboardShortcutsModalContentProps) {
|
||||||
|
const { isOsx } = useSelector(createSystemStatusSelector());
|
||||||
const allShortcuts = getShortcuts();
|
const allShortcuts = getShortcuts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent onModalClose={onModalClose}>
|
<ModalContent onModalClose={onModalClose}>
|
||||||
<ModalHeader>
|
<ModalHeader>{translate('KeyboardShortcuts')}</ModalHeader>
|
||||||
{translate('KeyboardShortcuts')}
|
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
{
|
{allShortcuts.map((shortcut) => {
|
||||||
allShortcuts.map((shortcut) => {
|
return (
|
||||||
return (
|
<div key={shortcut.name} className={styles.shortcut}>
|
||||||
<div
|
<div className={styles.key}>
|
||||||
key={shortcut.name}
|
{getShortcutKey(shortcut.key, isOsx)}
|
||||||
className={styles.shortcut}
|
|
||||||
>
|
|
||||||
<div className={styles.key}>
|
|
||||||
{getShortcutKey(shortcut.key, isOsx)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{shortcut.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})
|
<div>{shortcut.name}</div>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||||
onPress={onModalClose}
|
|
||||||
>
|
|
||||||
{translate('Close')}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcutsModalContent.propTypes = {
|
|
||||||
isOsx: PropTypes.bool.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KeyboardShortcutsModalContent;
|
export default KeyboardShortcutsModalContent;
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
|
||||||
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
createSystemStatusSelector(),
|
|
||||||
(systemStatus) => {
|
|
||||||
return {
|
|
||||||
isOsx: systemStatus.isOsx
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
import _ from 'lodash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Autosuggest from 'react-autosuggest';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import FuseWorker from './fuse.worker';
|
|
||||||
import MovieSearchResult from './MovieSearchResult';
|
|
||||||
import styles from './MovieSearchInput.css';
|
|
||||||
|
|
||||||
const ADD_NEW_TYPE = 'addNew';
|
|
||||||
|
|
||||||
class MovieSearchInput extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this._autosuggest = null;
|
|
||||||
this._worker = null;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
value: '',
|
|
||||||
suggestions: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.bindShortcut(shortcuts.MOVIE_SEARCH_INPUT.key, this.focusInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this._worker) {
|
|
||||||
this._worker.removeEventListener('message', this.onSuggestionsReceived, false);
|
|
||||||
this._worker.terminate();
|
|
||||||
this._worker = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getWorker() {
|
|
||||||
if (!this._worker) {
|
|
||||||
this._worker = new FuseWorker();
|
|
||||||
this._worker.addEventListener('message', this.onSuggestionsReceived, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._worker;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
setAutosuggestRef = (ref) => {
|
|
||||||
this._autosuggest = ref;
|
|
||||||
};
|
|
||||||
|
|
||||||
focusInput = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
this._autosuggest.input.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
getSectionSuggestions(section) {
|
|
||||||
return section.suggestions;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSectionTitle(section) {
|
|
||||||
return (
|
|
||||||
<div className={styles.sectionTitle}>
|
|
||||||
{section.title}
|
|
||||||
|
|
||||||
{
|
|
||||||
section.loading &&
|
|
||||||
<LoadingIndicator
|
|
||||||
className={styles.loading}
|
|
||||||
rippleClassName={styles.ripple}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSuggestionValue({ title }) {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSuggestion(item, { query }) {
|
|
||||||
if (item.type === ADD_NEW_TYPE) {
|
|
||||||
return (
|
|
||||||
<div className={styles.addNewMovieSuggestion}>
|
|
||||||
{translate('SearchForQuery', { query })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MovieSearchResult
|
|
||||||
{...item.item}
|
|
||||||
match={item.matches[0]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
goToMovie(item) {
|
|
||||||
this.setState({ value: '' });
|
|
||||||
this.props.onGoToMovie(item.item.titleSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.setState({
|
|
||||||
value: '',
|
|
||||||
suggestions: [],
|
|
||||||
loading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onChange = (event, { newValue, method }) => {
|
|
||||||
if (method === 'up' || method === 'down') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ value: newValue });
|
|
||||||
};
|
|
||||||
|
|
||||||
onKeyDown = (event) => {
|
|
||||||
if (event.shiftKey || event.altKey || event.ctrlKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
this.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key !== 'Tab' && event.key !== 'Enter') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
suggestions,
|
|
||||||
value
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const {
|
|
||||||
highlightedSectionIndex,
|
|
||||||
highlightedSuggestionIndex
|
|
||||||
} = this._autosuggest.state;
|
|
||||||
|
|
||||||
if (!suggestions.length || highlightedSectionIndex) {
|
|
||||||
this.props.onGoToAddNewMovie(value);
|
|
||||||
this._autosuggest.input.blur();
|
|
||||||
this.reset();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If an suggestion is not selected go to the first movie,
|
|
||||||
// otherwise go to the selected movie.
|
|
||||||
|
|
||||||
if (highlightedSuggestionIndex == null) {
|
|
||||||
this.goToMovie(suggestions[0]);
|
|
||||||
} else {
|
|
||||||
this.goToMovie(suggestions[highlightedSuggestionIndex]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._autosuggest.input.blur();
|
|
||||||
this.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
onBlur = () => {
|
|
||||||
this.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
onSuggestionsFetchRequested = ({ value }) => {
|
|
||||||
if (!this.state.loading) {
|
|
||||||
this.setState({
|
|
||||||
loading: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.requestSuggestions(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
requestSuggestions = _.debounce((value) => {
|
|
||||||
if (!this.state.loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestLoading = this.state.requestLoading;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
requestValue: value,
|
|
||||||
requestLoading: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!requestLoading) {
|
|
||||||
const payload = {
|
|
||||||
value,
|
|
||||||
movies: this.props.movies
|
|
||||||
};
|
|
||||||
|
|
||||||
this.getWorker().postMessage(payload);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
onSuggestionsReceived = (message) => {
|
|
||||||
const {
|
|
||||||
value,
|
|
||||||
suggestions
|
|
||||||
} = message.data;
|
|
||||||
|
|
||||||
if (!this.state.loading) {
|
|
||||||
this.setState({
|
|
||||||
requestValue: null,
|
|
||||||
requestLoading: false
|
|
||||||
});
|
|
||||||
} else if (value === this.state.requestValue) {
|
|
||||||
this.setState({
|
|
||||||
suggestions,
|
|
||||||
requestValue: null,
|
|
||||||
requestLoading: false,
|
|
||||||
loading: false
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
suggestions,
|
|
||||||
requestLoading: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
value: this.state.requestValue,
|
|
||||||
movies: this.props.movies
|
|
||||||
};
|
|
||||||
|
|
||||||
this.getWorker().postMessage(payload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onSuggestionsClearRequested = () => {
|
|
||||||
this.setState({
|
|
||||||
suggestions: [],
|
|
||||||
loading: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onSuggestionSelected = (event, { suggestion }) => {
|
|
||||||
if (suggestion.type === ADD_NEW_TYPE) {
|
|
||||||
this.props.onGoToAddNewMovie(this.state.value);
|
|
||||||
} else {
|
|
||||||
this.goToMovie(suggestion);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
value,
|
|
||||||
loading,
|
|
||||||
suggestions
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const suggestionGroups = [];
|
|
||||||
|
|
||||||
if (suggestions.length || loading) {
|
|
||||||
suggestionGroups.push({
|
|
||||||
title: translate('ExistingMovies'),
|
|
||||||
loading,
|
|
||||||
suggestions
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestionGroups.push({
|
|
||||||
title: translate('AddNewMovie'),
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
type: ADD_NEW_TYPE,
|
|
||||||
title: value
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputProps = {
|
|
||||||
ref: this.setInputRef,
|
|
||||||
className: styles.input,
|
|
||||||
name: 'movieSearch',
|
|
||||||
value,
|
|
||||||
placeholder: translate('Search'),
|
|
||||||
autoComplete: 'off',
|
|
||||||
spellCheck: false,
|
|
||||||
onChange: this.onChange,
|
|
||||||
onKeyDown: this.onKeyDown,
|
|
||||||
onBlur: this.onBlur,
|
|
||||||
onFocus: this.onFocus
|
|
||||||
};
|
|
||||||
|
|
||||||
const theme = {
|
|
||||||
container: styles.container,
|
|
||||||
containerOpen: styles.containerOpen,
|
|
||||||
suggestionsContainer: styles.movieContainer,
|
|
||||||
suggestionsList: styles.list,
|
|
||||||
suggestion: styles.listItem,
|
|
||||||
suggestionHighlighted: styles.highlighted
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<Icon name={icons.SEARCH} />
|
|
||||||
|
|
||||||
<Autosuggest
|
|
||||||
ref={this.setAutosuggestRef}
|
|
||||||
id={name}
|
|
||||||
inputProps={inputProps}
|
|
||||||
theme={theme}
|
|
||||||
focusInputOnSuggestionClick={false}
|
|
||||||
multiSection={true}
|
|
||||||
suggestions={suggestionGroups}
|
|
||||||
getSectionSuggestions={this.getSectionSuggestions}
|
|
||||||
renderSectionTitle={this.renderSectionTitle}
|
|
||||||
getSuggestionValue={this.getSuggestionValue}
|
|
||||||
renderSuggestion={this.renderSuggestion}
|
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MovieSearchInput.propTypes = {
|
|
||||||
movies: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
onGoToMovie: PropTypes.func.isRequired,
|
|
||||||
onGoToAddNewMovie: PropTypes.func.isRequired,
|
|
||||||
bindShortcut: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default keyboardShortcuts(MovieSearchInput);
|
|
||||||
457
frontend/src/Components/Page/Header/MovieSearchInput.tsx
Normal file
457
frontend/src/Components/Page/Header/MovieSearchInput.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
import { push } from 'connected-react-router';
|
||||||
|
import { ExtendedKeyboardEvent } from 'mousetrap';
|
||||||
|
import React, {
|
||||||
|
FormEvent,
|
||||||
|
KeyboardEvent,
|
||||||
|
SyntheticEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import Autosuggest from 'react-autosuggest';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { Tag } from 'App/State/TagsAppState';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
|
||||||
|
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import Movie from 'Movie/Movie';
|
||||||
|
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
||||||
|
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
|
||||||
|
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import MovieSearchResult from './MovieSearchResult';
|
||||||
|
import styles from './MovieSearchInput.css';
|
||||||
|
|
||||||
|
const ADD_NEW_TYPE = 'addNew';
|
||||||
|
|
||||||
|
interface Match {
|
||||||
|
key: string;
|
||||||
|
refIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddNewMovieSuggestion {
|
||||||
|
type: 'addNew';
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestedMovie
|
||||||
|
extends Pick<
|
||||||
|
Movie,
|
||||||
|
| 'title'
|
||||||
|
| 'year'
|
||||||
|
| 'titleSlug'
|
||||||
|
| 'sortTitle'
|
||||||
|
| 'images'
|
||||||
|
| 'alternateTitles'
|
||||||
|
| 'tmdbId'
|
||||||
|
| 'imdbId'
|
||||||
|
> {
|
||||||
|
firstCharacter: string;
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieSuggestion {
|
||||||
|
title: string;
|
||||||
|
indices: number[];
|
||||||
|
item: SuggestedMovie;
|
||||||
|
matches: Match[];
|
||||||
|
refIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
loading?: boolean;
|
||||||
|
suggestions: MovieSuggestion[] | AddNewMovieSuggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUnoptimizedSelector() {
|
||||||
|
return createSelector(
|
||||||
|
createAllMoviesSelector(),
|
||||||
|
createTagsSelector(),
|
||||||
|
(allMovies, allTags) => {
|
||||||
|
return allMovies.map((movie): SuggestedMovie => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
titleSlug,
|
||||||
|
sortTitle,
|
||||||
|
images,
|
||||||
|
alternateTitles = [],
|
||||||
|
tmdbId,
|
||||||
|
imdbId,
|
||||||
|
tags = [],
|
||||||
|
} = movie;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
titleSlug,
|
||||||
|
sortTitle,
|
||||||
|
images,
|
||||||
|
alternateTitles,
|
||||||
|
tmdbId,
|
||||||
|
imdbId,
|
||||||
|
firstCharacter: title.charAt(0).toLowerCase(),
|
||||||
|
tags: tags.reduce<Tag[]>((acc, id) => {
|
||||||
|
const matchingTag = allTags.find((tag) => tag.id === id);
|
||||||
|
|
||||||
|
if (matchingTag) {
|
||||||
|
acc.push(matchingTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMoviesSelector() {
|
||||||
|
return createDeepEqualSelector(
|
||||||
|
createUnoptimizedSelector(),
|
||||||
|
(movies) => movies
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MovieSearchInput() {
|
||||||
|
const movies = useSelector(createMoviesSelector());
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
||||||
|
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [requestLoading, setRequestLoading] = useState(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<MovieSuggestion[]>([]);
|
||||||
|
|
||||||
|
const autosuggestRef = useRef<Autosuggest>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const worker = useRef<Worker | null>(null);
|
||||||
|
const isLoading = useRef(false);
|
||||||
|
const requestValue = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const suggestionGroups = useMemo(() => {
|
||||||
|
const result: Section[] = [];
|
||||||
|
|
||||||
|
if (suggestions.length || isLoading.current) {
|
||||||
|
result.push({
|
||||||
|
title: translate('ExistingMovies'),
|
||||||
|
loading: isLoading.current,
|
||||||
|
suggestions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
title: translate('AddNewMovie'),
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
type: ADD_NEW_TYPE,
|
||||||
|
title: value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [suggestions, value]);
|
||||||
|
|
||||||
|
const handleSuggestionsReceived = useCallback(
|
||||||
|
(message: { data: { value: string; suggestions: MovieSuggestion[] } }) => {
|
||||||
|
const { value, suggestions } = message.data;
|
||||||
|
|
||||||
|
if (!isLoading.current) {
|
||||||
|
requestValue.current = null;
|
||||||
|
setRequestLoading(false);
|
||||||
|
} else if (value === requestValue.current) {
|
||||||
|
setSuggestions(suggestions);
|
||||||
|
requestValue.current = null;
|
||||||
|
setRequestLoading(false);
|
||||||
|
isLoading.current = false;
|
||||||
|
// setLoading(false);
|
||||||
|
} else {
|
||||||
|
setSuggestions(suggestions);
|
||||||
|
setRequestLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
value: requestValue,
|
||||||
|
movies,
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.current?.postMessage(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[movies]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestSuggestions = useDebouncedCallback((value: string) => {
|
||||||
|
if (!isLoading.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestValue.current = value;
|
||||||
|
setRequestLoading(true);
|
||||||
|
|
||||||
|
if (!requestLoading) {
|
||||||
|
const payload = {
|
||||||
|
value,
|
||||||
|
movies,
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.current?.postMessage(payload);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setValue('');
|
||||||
|
setSuggestions([]);
|
||||||
|
// setLoading(false);
|
||||||
|
isLoading.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const focusInput = useCallback((event: ExtendedKeyboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSectionSuggestions = useCallback((section: Section) => {
|
||||||
|
return section.suggestions;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderSectionTitle = useCallback((section: Section) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
{section.title}
|
||||||
|
|
||||||
|
{section.loading && (
|
||||||
|
<LoadingIndicator
|
||||||
|
className={styles.loading}
|
||||||
|
rippleClassName={styles.ripple}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSuggestionValue = useCallback(({ title }: { title: string }) => {
|
||||||
|
return title;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderSuggestion = useCallback(
|
||||||
|
(
|
||||||
|
item: AddNewMovieSuggestion | MovieSuggestion,
|
||||||
|
{ query }: { query: string }
|
||||||
|
) => {
|
||||||
|
if ('type' in item) {
|
||||||
|
return (
|
||||||
|
<div className={styles.addNewMovieSuggestion}>
|
||||||
|
{translate('SearchForQuery', { query })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MovieSearchResult {...item.item} match={item.matches[0]} />;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(
|
||||||
|
_event: FormEvent<HTMLElement>,
|
||||||
|
{
|
||||||
|
newValue,
|
||||||
|
method,
|
||||||
|
}: {
|
||||||
|
newValue: string;
|
||||||
|
method: 'down' | 'up' | 'escape' | 'enter' | 'click' | 'type';
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (method === 'up' || method === 'down') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(newValue);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLElement>) => {
|
||||||
|
if (event.shiftKey || event.altKey || event.ctrlKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'Tab' && event.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autosuggestRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { highlightedSectionIndex, highlightedSuggestionIndex } =
|
||||||
|
autosuggestRef.current.state;
|
||||||
|
|
||||||
|
if (!suggestions.length || highlightedSectionIndex) {
|
||||||
|
dispatch(
|
||||||
|
push(
|
||||||
|
`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
inputRef.current?.blur();
|
||||||
|
reset();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a suggestion is not selected go to the first movie,
|
||||||
|
// otherwise go to the selected movie.
|
||||||
|
|
||||||
|
const selectedSuggestion =
|
||||||
|
highlightedSuggestionIndex == null
|
||||||
|
? suggestions[0]
|
||||||
|
: suggestions[highlightedSuggestionIndex];
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
push(
|
||||||
|
`${window.Radarr.urlBase}/movie/${selectedSuggestion.item.titleSlug}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
inputRef.current?.blur();
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
[value, suggestions, dispatch, reset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
const handleSuggestionsFetchRequested = useCallback(
|
||||||
|
({ value }: { value: string }) => {
|
||||||
|
isLoading.current = true;
|
||||||
|
|
||||||
|
requestSuggestions(value);
|
||||||
|
},
|
||||||
|
[requestSuggestions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSuggestionsClearRequested = useCallback(() => {
|
||||||
|
setSuggestions([]);
|
||||||
|
isLoading.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSuggestionSelected = useCallback(
|
||||||
|
(
|
||||||
|
_event: SyntheticEvent,
|
||||||
|
{ suggestion }: { suggestion: MovieSuggestion | AddNewMovieSuggestion }
|
||||||
|
) => {
|
||||||
|
if ('type' in suggestion) {
|
||||||
|
dispatch(
|
||||||
|
push(
|
||||||
|
`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setValue('');
|
||||||
|
dispatch(
|
||||||
|
push(`${window.Radarr.urlBase}/movie/${suggestion.item.titleSlug}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[value, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputProps = {
|
||||||
|
ref: inputRef,
|
||||||
|
className: styles.input,
|
||||||
|
name: 'movieSearch',
|
||||||
|
value,
|
||||||
|
placeholder: translate('Search'),
|
||||||
|
autoComplete: 'off',
|
||||||
|
spellCheck: false,
|
||||||
|
onChange: handleChange,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
onBlur: handleBlur,
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
container: styles.container,
|
||||||
|
containerOpen: styles.containerOpen,
|
||||||
|
suggestionsContainer: styles.movieContainer,
|
||||||
|
suggestionsList: styles.list,
|
||||||
|
suggestion: styles.listItem,
|
||||||
|
suggestionHighlighted: styles.highlighted,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
worker.current = new Worker(new URL('./fuse.worker.ts', import.meta.url));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (worker.current) {
|
||||||
|
worker.current.terminate();
|
||||||
|
worker.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
worker.current?.addEventListener(
|
||||||
|
'message',
|
||||||
|
handleSuggestionsReceived,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (worker.current) {
|
||||||
|
worker.current.removeEventListener(
|
||||||
|
'message',
|
||||||
|
handleSuggestionsReceived,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [handleSuggestionsReceived]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bindShortcut('focusMovieSearchInput', focusInput);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindShortcut('focusMovieSearchInput');
|
||||||
|
};
|
||||||
|
}, [bindShortcut, unbindShortcut, focusInput]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Icon name={icons.SEARCH} />
|
||||||
|
|
||||||
|
<Autosuggest
|
||||||
|
ref={autosuggestRef}
|
||||||
|
inputProps={inputProps}
|
||||||
|
theme={theme}
|
||||||
|
focusInputOnSuggestionClick={false}
|
||||||
|
multiSection={true}
|
||||||
|
suggestions={suggestionGroups}
|
||||||
|
getSectionSuggestions={getSectionSuggestions}
|
||||||
|
renderSectionTitle={renderSectionTitle}
|
||||||
|
getSuggestionValue={getSuggestionValue}
|
||||||
|
renderSuggestion={renderSuggestion}
|
||||||
|
onSuggestionSelected={handleSuggestionSelected}
|
||||||
|
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
||||||
|
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MovieSearchInput;
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { push } from 'connected-react-router';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
|
||||||
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
|
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
|
||||||
import MovieSearchInput from './MovieSearchInput';
|
|
||||||
|
|
||||||
function createCleanMovieSelector() {
|
|
||||||
return createSelector(
|
|
||||||
createAllMoviesSelector(),
|
|
||||||
createTagsSelector(),
|
|
||||||
(allMovies, allTags) => {
|
|
||||||
return allMovies.map((movie) => {
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
titleSlug,
|
|
||||||
sortTitle,
|
|
||||||
year,
|
|
||||||
images,
|
|
||||||
alternateTitles = [],
|
|
||||||
tmdbId,
|
|
||||||
imdbId,
|
|
||||||
tags = []
|
|
||||||
} = movie;
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
titleSlug,
|
|
||||||
sortTitle,
|
|
||||||
year,
|
|
||||||
images,
|
|
||||||
alternateTitles,
|
|
||||||
tmdbId,
|
|
||||||
imdbId,
|
|
||||||
firstCharacter: title.charAt(0).toLowerCase(),
|
|
||||||
tags: tags.reduce((acc, id) => {
|
|
||||||
const matchingTag = allTags.find((tag) => tag.id === id);
|
|
||||||
|
|
||||||
if (matchingTag) {
|
|
||||||
acc.push(matchingTag);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, [])
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createDeepEqualSelector(
|
|
||||||
createCleanMovieSelector(),
|
|
||||||
(movies) => {
|
|
||||||
return {
|
|
||||||
movies
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapDispatchToProps(dispatch, props) {
|
|
||||||
return {
|
|
||||||
onGoToMovie(titleSlug) {
|
|
||||||
dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
|
|
||||||
},
|
|
||||||
|
|
||||||
onGoToAddNewMovie(query) {
|
|
||||||
dispatch(push(`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchInput);
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Label from 'Components/Label';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
import MoviePoster from 'Movie/MoviePoster';
|
|
||||||
import styles from './MovieSearchResult.css';
|
|
||||||
|
|
||||||
function MovieSearchResult(props) {
|
|
||||||
const {
|
|
||||||
match,
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
images,
|
|
||||||
alternateTitles,
|
|
||||||
tmdbId,
|
|
||||||
imdbId,
|
|
||||||
tags
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
let alternateTitle = null;
|
|
||||||
let tag = null;
|
|
||||||
|
|
||||||
if (match.key === 'alternateTitles.title') {
|
|
||||||
alternateTitle = alternateTitles[match.refIndex];
|
|
||||||
} else if (match.key === 'tags.label') {
|
|
||||||
tag = tags[match.refIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.result}>
|
|
||||||
<MoviePoster
|
|
||||||
className={styles.poster}
|
|
||||||
images={images}
|
|
||||||
size={250}
|
|
||||||
lazy={false}
|
|
||||||
overflow={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.titles}>
|
|
||||||
<div className={styles.title}>
|
|
||||||
{title} { year > 0 ? `(${year})` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
alternateTitle ?
|
|
||||||
<div className={styles.alternateTitle}>
|
|
||||||
{alternateTitle.title}
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
match.key === 'tmdbId' && tmdbId ?
|
|
||||||
<div className={styles.alternateTitle}>
|
|
||||||
TmdbId: {tmdbId}
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
match.key === 'imdbId' && imdbId ?
|
|
||||||
<div className={styles.alternateTitle}>
|
|
||||||
ImdbId: {imdbId}
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
tag ?
|
|
||||||
<div className={styles.tagContainer}>
|
|
||||||
<Label
|
|
||||||
key={tag.id}
|
|
||||||
kind={kinds.INFO}
|
|
||||||
>
|
|
||||||
{tag.label}
|
|
||||||
</Label>
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
MovieSearchResult.propTypes = {
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
year: PropTypes.number.isRequired,
|
|
||||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
tmdbId: PropTypes.number,
|
|
||||||
imdbId: PropTypes.string,
|
|
||||||
tags: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
match: PropTypes.object.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MovieSearchResult;
|
|
||||||
70
frontend/src/Components/Page/Header/MovieSearchResult.tsx
Normal file
70
frontend/src/Components/Page/Header/MovieSearchResult.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Tag } from 'App/State/TagsAppState';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import MoviePoster from 'Movie/MoviePoster';
|
||||||
|
import { SuggestedMovie } from './MovieSearchInput';
|
||||||
|
import styles from './MovieSearchResult.css';
|
||||||
|
|
||||||
|
interface Match {
|
||||||
|
key: string;
|
||||||
|
refIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieSearchResultProps extends SuggestedMovie {
|
||||||
|
match: Match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MovieSearchResult(props: MovieSearchResultProps) {
|
||||||
|
const { match, title, year, images, alternateTitles, tmdbId, imdbId, tags } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
let alternateTitle = null;
|
||||||
|
let tag: Tag | null = null;
|
||||||
|
|
||||||
|
if (match.key === 'alternateTitles.title') {
|
||||||
|
alternateTitle = alternateTitles[match.refIndex];
|
||||||
|
} else if (match.key === 'tags.label') {
|
||||||
|
tag = tags[match.refIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.result}>
|
||||||
|
<MoviePoster
|
||||||
|
className={styles.poster}
|
||||||
|
images={images}
|
||||||
|
size={250}
|
||||||
|
lazy={false}
|
||||||
|
overflow={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.titles}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{title} {year > 0 ? `(${year})` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alternateTitle ? (
|
||||||
|
<div className={styles.alternateTitle}>{alternateTitle.title}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{match.key === 'tmdbId' && tmdbId ? (
|
||||||
|
<div className={styles.alternateTitle}>TmdbId: {tmdbId}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{match.key === 'imdbId' && imdbId ? (
|
||||||
|
<div className={styles.alternateTitle}>ImdbId: {imdbId}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tag ? (
|
||||||
|
<div className={styles.tagContainer}>
|
||||||
|
<Label key={tag.id} kind={kinds.INFO}>
|
||||||
|
{tag.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MovieSearchResult;
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logoLink {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.logoFull,
|
.logoFull,
|
||||||
.logo {
|
.logo {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ interface CssExports {
|
||||||
'logo': string;
|
'logo': string;
|
||||||
'logoContainer': string;
|
'logoContainer': string;
|
||||||
'logoFull': string;
|
'logoFull': string;
|
||||||
|
'logoLink': string;
|
||||||
'right': string;
|
'right': string;
|
||||||
'sidebarToggleContainer': string;
|
'sidebarToggleContainer': string;
|
||||||
'translate': string;
|
'translate': string;
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
|
|
||||||
import IconButton from 'Components/Link/IconButton';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
|
||||||
import MovieSearchInputConnector from './MovieSearchInputConnector';
|
|
||||||
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
|
||||||
import styles from './PageHeader.css';
|
|
||||||
|
|
||||||
class PageHeader extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isKeyboardShortcutsModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
onOpenKeyboardShortcutsModal = () => {
|
|
||||||
this.setState({ isKeyboardShortcutsModalOpen: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onKeyboardShortcutsModalClose = () => {
|
|
||||||
this.setState({ isKeyboardShortcutsModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
onSidebarToggle,
|
|
||||||
isSmallScreen
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.logoContainer}>
|
|
||||||
<Link
|
|
||||||
className={styles.logoLink}
|
|
||||||
to={'/'}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
className={isSmallScreen ? styles.logo : styles.logoFull}
|
|
||||||
src={isSmallScreen ? `${window.Radarr.urlBase}/Content/Images/logo.png` : `${window.Radarr.urlBase}/Content/Images/logo-full.png`}
|
|
||||||
alt="Radarr Logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.sidebarToggleContainer}>
|
|
||||||
<IconButton
|
|
||||||
id="sidebar-toggle-button"
|
|
||||||
name={icons.NAVBAR_COLLAPSE}
|
|
||||||
onPress={onSidebarToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MovieSearchInputConnector />
|
|
||||||
|
|
||||||
<div className={styles.right}>
|
|
||||||
<IconButton
|
|
||||||
className={styles.donate}
|
|
||||||
name={icons.HEART}
|
|
||||||
aria-label={translate('Donate')}
|
|
||||||
to="https://radarr.video/donate"
|
|
||||||
size={14}
|
|
||||||
title={translate('Donate')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
className={styles.translate}
|
|
||||||
title={translate('SuggestTranslationChange')}
|
|
||||||
name={icons.TRANSLATE}
|
|
||||||
to="https://translate.servarr.com/projects/radarr/radarr/"
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageHeaderActionsMenu
|
|
||||||
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<KeyboardShortcutsModal
|
|
||||||
isOpen={this.state.isKeyboardShortcutsModalOpen}
|
|
||||||
onModalClose={this.onKeyboardShortcutsModalClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageHeader.propTypes = {
|
|
||||||
onSidebarToggle: PropTypes.func.isRequired,
|
|
||||||
isSmallScreen: PropTypes.bool.isRequired,
|
|
||||||
bindShortcut: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default keyboardShortcuts(PageHeader);
|
|
||||||
109
frontend/src/Components/Page/Header/PageHeader.tsx
Normal file
109
frontend/src/Components/Page/Header/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import { setIsSidebarVisible } from 'Store/Actions/appActions';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||||
|
import MovieSearchInput from './MovieSearchInput';
|
||||||
|
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||||
|
import styles from './PageHeader.css';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageHeader({ isSmallScreen }: PageHeaderProps) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const { isSidebarVisible } = useSelector((state: AppState) => state.app);
|
||||||
|
|
||||||
|
const [isKeyboardShortcutsModalOpen, setIsKeyboardShortcutsModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
||||||
|
|
||||||
|
const handleSidebarToggle = useCallback(() => {
|
||||||
|
dispatch(setIsSidebarVisible({ isSidebarVisible: !isSidebarVisible }));
|
||||||
|
}, [isSidebarVisible, dispatch]);
|
||||||
|
|
||||||
|
const handleOpenKeyboardShortcutsModal = useCallback(() => {
|
||||||
|
setIsKeyboardShortcutsModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyboardShortcutsModalClose = useCallback(() => {
|
||||||
|
setIsKeyboardShortcutsModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bindShortcut(
|
||||||
|
'openKeyboardShortcutsModal',
|
||||||
|
handleOpenKeyboardShortcutsModal
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindShortcut('openKeyboardShortcutsModal');
|
||||||
|
};
|
||||||
|
}, [handleOpenKeyboardShortcutsModal, bindShortcut, unbindShortcut]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.logoContainer}>
|
||||||
|
<Link className={styles.logoLink} to="/">
|
||||||
|
<img
|
||||||
|
className={isSmallScreen ? styles.logo : styles.logoFull}
|
||||||
|
src={
|
||||||
|
isSmallScreen
|
||||||
|
? `${window.Radarr.urlBase}/Content/Images/logo.png`
|
||||||
|
: `${window.Radarr.urlBase}/Content/Images/logo-full.png`
|
||||||
|
}
|
||||||
|
alt="Radarr Logo"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sidebarToggleContainer}>
|
||||||
|
<IconButton
|
||||||
|
id="sidebar-toggle-button"
|
||||||
|
name={icons.NAVBAR_COLLAPSE}
|
||||||
|
onPress={handleSidebarToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MovieSearchInput />
|
||||||
|
|
||||||
|
<div className={styles.right}>
|
||||||
|
<IconButton
|
||||||
|
className={styles.donate}
|
||||||
|
name={icons.HEART}
|
||||||
|
aria-label={translate('Donate')}
|
||||||
|
to="https://radarr.video/donate"
|
||||||
|
size={14}
|
||||||
|
title={translate('Donate')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.translate}
|
||||||
|
title={translate('SuggestTranslationChange')}
|
||||||
|
name={icons.TRANSLATE}
|
||||||
|
to="https://translate.servarr.com/projects/radarr/radarr/"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageHeaderActionsMenu
|
||||||
|
onKeyboardShortcutsPress={handleOpenKeyboardShortcutsModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={isKeyboardShortcutsModalOpen}
|
||||||
|
onModalClose={handleKeyboardShortcutsModalClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
|
import { SuggestedMovie } from './MovieSearchInput';
|
||||||
|
|
||||||
const fuseOptions = {
|
const fuseOptions = {
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
|
|
@ -6,35 +7,27 @@ const fuseOptions = {
|
||||||
ignoreLocation: true,
|
ignoreLocation: true,
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
minMatchCharLength: 1,
|
minMatchCharLength: 1,
|
||||||
keys: [
|
keys: ['title', 'alternateTitles.title', 'tmdbId', 'imdbId', 'tags.label'],
|
||||||
'title',
|
|
||||||
'alternateTitles.title',
|
|
||||||
'tmdbId',
|
|
||||||
'imdbId',
|
|
||||||
'tags.label'
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSuggestions(movies, value) {
|
function getSuggestions(movies: SuggestedMovie[], value: string) {
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
|
|
||||||
if (value.length === 1) {
|
if (value.length === 1) {
|
||||||
for (let i = 0; i < movies.length; i++) {
|
for (let i = 0; i < movies.length; i++) {
|
||||||
const s = movies[i];
|
const m = movies[i];
|
||||||
if (s.firstCharacter === value.toLowerCase()) {
|
if (m.firstCharacter === value.toLowerCase()) {
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
item: movies[i],
|
item: movies[i],
|
||||||
indices: [
|
indices: [[0, 0]],
|
||||||
[0, 0]
|
|
||||||
],
|
|
||||||
matches: [
|
matches: [
|
||||||
{
|
{
|
||||||
value: s.title,
|
value: m.title,
|
||||||
key: 'title'
|
key: 'title',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
refIndex: 0
|
refIndex: 0,
|
||||||
});
|
});
|
||||||
if (suggestions.length > limit) {
|
if (suggestions.length > limit) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -49,21 +42,18 @@ function getSuggestions(movies, value) {
|
||||||
return suggestions;
|
return suggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
onmessage = function(e) {
|
onmessage = function (e) {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { movies, value } = e.data;
|
||||||
movies,
|
|
||||||
value
|
|
||||||
} = e.data;
|
|
||||||
|
|
||||||
const suggestions = getSuggestions(movies, value);
|
const suggestions = getSuggestions(movies, value);
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
value,
|
value,
|
||||||
suggestions
|
suggestions,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.postMessage(results);
|
self.postMessage(results);
|
||||||
File diff suppressed because one or more lines are too long
19
frontend/src/Components/Page/LoadingPage.tsx
Normal file
19
frontend/src/Components/Page/LoadingPage.tsx
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,143 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import AppUpdatedModal from 'App/AppUpdatedModal';
|
|
||||||
import ColorImpairedContext from 'App/ColorImpairedContext';
|
|
||||||
import ConnectionLostModal from 'App/ConnectionLostModal';
|
|
||||||
import SignalRConnector from 'Components/SignalRConnector';
|
|
||||||
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
|
|
||||||
import locationShape from 'Helpers/Props/Shapes/locationShape';
|
|
||||||
import PageHeader from './Header/PageHeader';
|
|
||||||
import PageSidebar from './Sidebar/PageSidebar';
|
|
||||||
import styles from './Page.css';
|
|
||||||
|
|
||||||
class Page extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isUpdatedModalOpen: false,
|
|
||||||
isConnectionLostModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
window.addEventListener('resize', this.onResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const {
|
|
||||||
isDisconnected,
|
|
||||||
isUpdated
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (!prevProps.isUpdated && isUpdated) {
|
|
||||||
this.setState({ isUpdatedModalOpen: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.isDisconnected !== isDisconnected) {
|
|
||||||
this.setState({ isConnectionLostModalOpen: isDisconnected });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
window.removeEventListener('resize', this.onResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onResize = () => {
|
|
||||||
this.props.onResize({
|
|
||||||
width: window.innerWidth,
|
|
||||||
height: window.innerHeight
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onUpdatedModalClose = () => {
|
|
||||||
this.setState({ isUpdatedModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
onConnectionLostModalClose = () => {
|
|
||||||
this.setState({ isConnectionLostModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
location,
|
|
||||||
children,
|
|
||||||
isSmallScreen,
|
|
||||||
isSidebarVisible,
|
|
||||||
enableColorImpairedMode,
|
|
||||||
authenticationEnabled,
|
|
||||||
onSidebarToggle,
|
|
||||||
onSidebarVisibleChange
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColorImpairedContext.Provider value={enableColorImpairedMode}>
|
|
||||||
<div className={className}>
|
|
||||||
<SignalRConnector />
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
onSidebarToggle={onSidebarToggle}
|
|
||||||
isSmallScreen={isSmallScreen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.main}>
|
|
||||||
<PageSidebar
|
|
||||||
location={location}
|
|
||||||
isSmallScreen={isSmallScreen}
|
|
||||||
isSidebarVisible={isSidebarVisible}
|
|
||||||
onSidebarVisibleChange={onSidebarVisibleChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppUpdatedModal
|
|
||||||
isOpen={this.state.isUpdatedModalOpen}
|
|
||||||
onModalClose={this.onUpdatedModalClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConnectionLostModal
|
|
||||||
isOpen={this.state.isConnectionLostModalOpen}
|
|
||||||
onModalClose={this.onConnectionLostModalClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AuthenticationRequiredModal
|
|
||||||
isOpen={!authenticationEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ColorImpairedContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Page.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
location: locationShape.isRequired,
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
isSmallScreen: PropTypes.bool.isRequired,
|
|
||||||
isSidebarVisible: PropTypes.bool.isRequired,
|
|
||||||
isUpdated: PropTypes.bool.isRequired,
|
|
||||||
isDisconnected: PropTypes.bool.isRequired,
|
|
||||||
enableColorImpairedMode: PropTypes.bool.isRequired,
|
|
||||||
authenticationEnabled: PropTypes.bool.isRequired,
|
|
||||||
onResize: PropTypes.func.isRequired,
|
|
||||||
onSidebarToggle: PropTypes.func.isRequired,
|
|
||||||
onSidebarVisibleChange: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
Page.defaultProps = {
|
|
||||||
className: styles.page
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
116
frontend/src/Components/Page/Page.tsx
Normal file
116
frontend/src/Components/Page/Page.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppUpdatedModal from 'App/AppUpdatedModal';
|
||||||
|
import ColorImpairedContext from 'App/ColorImpairedContext';
|
||||||
|
import ConnectionLostModal from 'App/ConnectionLostModal';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import SignalRConnector from 'Components/SignalRConnector';
|
||||||
|
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
|
||||||
|
import useAppPage from 'Helpers/Hooks/useAppPage';
|
||||||
|
import { saveDimensions } from 'Store/Actions/appActions';
|
||||||
|
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||||
|
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||||
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
import ErrorPage from './ErrorPage';
|
||||||
|
import PageHeader from './Header/PageHeader';
|
||||||
|
import LoadingPage from './LoadingPage';
|
||||||
|
import PageSidebar from './Sidebar/PageSidebar';
|
||||||
|
import styles from './Page.css';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Page({ children }: PageProps) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { hasError, errors, isPopulated, isLocalStorageSupported } =
|
||||||
|
useAppPage();
|
||||||
|
const [isUpdatedModalOpen, setIsUpdatedModalOpen] = useState(false);
|
||||||
|
const [isConnectionLostModalOpen, setIsConnectionLostModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
|
||||||
|
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||||
|
const { authentication } = useSelector(createSystemStatusSelector());
|
||||||
|
const authenticationEnabled = authentication !== 'none';
|
||||||
|
const { isSidebarVisible, isUpdated, isDisconnected, version } = useSelector(
|
||||||
|
(state: AppState) => state.app
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdatedModalClose = useCallback(() => {
|
||||||
|
setIsUpdatedModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
saveDimensions({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [handleResize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDisconnected) {
|
||||||
|
setIsConnectionLostModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [isDisconnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdated) {
|
||||||
|
setIsUpdatedModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [isUpdated]);
|
||||||
|
|
||||||
|
if (hasError || !isLocalStorageSupported) {
|
||||||
|
return (
|
||||||
|
<ErrorPage
|
||||||
|
{...errors}
|
||||||
|
version={version}
|
||||||
|
isLocalStorageSupported={isLocalStorageSupported}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPopulated) {
|
||||||
|
return <LoadingPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColorImpairedContext.Provider value={enableColorImpairedMode}>
|
||||||
|
<div className={styles.page}>
|
||||||
|
<SignalRConnector />
|
||||||
|
|
||||||
|
<PageHeader isSmallScreen={isSmallScreen} />
|
||||||
|
|
||||||
|
<div className={styles.main}>
|
||||||
|
<PageSidebar
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
isSidebarVisible={isSidebarVisible}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppUpdatedModal
|
||||||
|
isOpen={isUpdatedModalOpen}
|
||||||
|
onModalClose={handleUpdatedModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConnectionLostModal isOpen={isConnectionLostModalOpen} />
|
||||||
|
|
||||||
|
<AuthenticationRequiredModal isOpen={!authenticationEnabled} />
|
||||||
|
</div>
|
||||||
|
</ColorImpairedContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
|
|
||||||
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
|
|
||||||
import { fetchMovies } from 'Store/Actions/movieActions';
|
|
||||||
import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions';
|
|
||||||
import { fetchImportLists, fetchIndexerFlags, fetchLanguages, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
|
|
||||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
|
||||||
import { fetchTags } from 'Store/Actions/tagActions';
|
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
|
||||||
import ErrorPage from './ErrorPage';
|
|
||||||
import LoadingPage from './LoadingPage';
|
|
||||||
import Page from './Page';
|
|
||||||
|
|
||||||
function testLocalStorage() {
|
|
||||||
const key = 'radarrTest';
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(key, key);
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAppProps = createSelector(
|
|
||||||
(state) => state.app.isSidebarVisible,
|
|
||||||
(state) => state.app.version,
|
|
||||||
(state) => state.app.isUpdated,
|
|
||||||
(state) => state.app.isDisconnected,
|
|
||||||
(isSidebarVisible, version, isUpdated, isDisconnected) => {
|
|
||||||
return {
|
|
||||||
isSidebarVisible,
|
|
||||||
version,
|
|
||||||
isUpdated,
|
|
||||||
isDisconnected
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectIsPopulated = createSelector(
|
|
||||||
(state) => state.movies.isPopulated,
|
|
||||||
(state) => state.customFilters.isPopulated,
|
|
||||||
(state) => state.tags.isPopulated,
|
|
||||||
(state) => state.settings.ui.isPopulated,
|
|
||||||
(state) => state.settings.qualityProfiles.isPopulated,
|
|
||||||
(state) => state.settings.languages.isPopulated,
|
|
||||||
(state) => state.settings.indexerFlags.isPopulated,
|
|
||||||
(state) => state.settings.importLists.isPopulated,
|
|
||||||
(state) => state.system.status.isPopulated,
|
|
||||||
(state) => state.movieCollections.isPopulated,
|
|
||||||
(state) => state.app.translations.isPopulated,
|
|
||||||
(
|
|
||||||
moviesIsPopulated,
|
|
||||||
customFiltersIsPopulated,
|
|
||||||
tagsIsPopulated,
|
|
||||||
uiSettingsIsPopulated,
|
|
||||||
qualityProfilesIsPopulated,
|
|
||||||
languagesIsPopulated,
|
|
||||||
indexerFlagsIsPopulated,
|
|
||||||
importListsIsPopulated,
|
|
||||||
systemStatusIsPopulated,
|
|
||||||
movieCollectionsIsPopulated,
|
|
||||||
translationsIsPopulated
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
moviesIsPopulated &&
|
|
||||||
customFiltersIsPopulated &&
|
|
||||||
tagsIsPopulated &&
|
|
||||||
uiSettingsIsPopulated &&
|
|
||||||
qualityProfilesIsPopulated &&
|
|
||||||
languagesIsPopulated &&
|
|
||||||
indexerFlagsIsPopulated &&
|
|
||||||
importListsIsPopulated &&
|
|
||||||
systemStatusIsPopulated &&
|
|
||||||
movieCollectionsIsPopulated &&
|
|
||||||
translationsIsPopulated
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectErrors = createSelector(
|
|
||||||
(state) => state.movies.error,
|
|
||||||
(state) => state.customFilters.error,
|
|
||||||
(state) => state.tags.error,
|
|
||||||
(state) => state.settings.ui.error,
|
|
||||||
(state) => state.settings.qualityProfiles.error,
|
|
||||||
(state) => state.settings.languages.error,
|
|
||||||
(state) => state.settings.indexerFlags.error,
|
|
||||||
(state) => state.settings.importLists.error,
|
|
||||||
(state) => state.system.status.error,
|
|
||||||
(state) => state.movieCollections.error,
|
|
||||||
(state) => state.app.translations.error,
|
|
||||||
(
|
|
||||||
moviesError,
|
|
||||||
customFiltersError,
|
|
||||||
tagsError,
|
|
||||||
uiSettingsError,
|
|
||||||
qualityProfilesError,
|
|
||||||
languagesError,
|
|
||||||
indexerFlagsError,
|
|
||||||
importListsError,
|
|
||||||
systemStatusError,
|
|
||||||
movieCollectionsError,
|
|
||||||
translationsError
|
|
||||||
) => {
|
|
||||||
const hasError = !!(
|
|
||||||
moviesError ||
|
|
||||||
customFiltersError ||
|
|
||||||
tagsError ||
|
|
||||||
uiSettingsError ||
|
|
||||||
qualityProfilesError ||
|
|
||||||
languagesError ||
|
|
||||||
indexerFlagsError ||
|
|
||||||
importListsError ||
|
|
||||||
systemStatusError ||
|
|
||||||
movieCollectionsError ||
|
|
||||||
translationsError
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasError,
|
|
||||||
customFiltersError,
|
|
||||||
tagsError,
|
|
||||||
uiSettingsError,
|
|
||||||
qualityProfilesError,
|
|
||||||
languagesError,
|
|
||||||
indexerFlagsError,
|
|
||||||
importListsError,
|
|
||||||
systemStatusError,
|
|
||||||
movieCollectionsError,
|
|
||||||
translationsError
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.settings.ui.item.enableColorImpairedMode,
|
|
||||||
selectIsPopulated,
|
|
||||||
selectErrors,
|
|
||||||
selectAppProps,
|
|
||||||
createDimensionsSelector(),
|
|
||||||
createSystemStatusSelector(),
|
|
||||||
(
|
|
||||||
enableColorImpairedMode,
|
|
||||||
isPopulated,
|
|
||||||
errors,
|
|
||||||
app,
|
|
||||||
dimensions,
|
|
||||||
systemStatus
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
...app,
|
|
||||||
...errors,
|
|
||||||
isPopulated,
|
|
||||||
isSmallScreen: dimensions.isSmallScreen,
|
|
||||||
authenticationEnabled: systemStatus.authentication !== 'none',
|
|
||||||
enableColorImpairedMode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapDispatchToProps(dispatch, props) {
|
|
||||||
return {
|
|
||||||
dispatchFetchMovies() {
|
|
||||||
dispatch(fetchMovies());
|
|
||||||
},
|
|
||||||
dispatchFetchMovieCollections() {
|
|
||||||
dispatch(fetchMovieCollections());
|
|
||||||
},
|
|
||||||
dispatchFetchCustomFilters() {
|
|
||||||
dispatch(fetchCustomFilters());
|
|
||||||
},
|
|
||||||
dispatchFetchTags() {
|
|
||||||
dispatch(fetchTags());
|
|
||||||
},
|
|
||||||
dispatchFetchQualityProfiles() {
|
|
||||||
dispatch(fetchQualityProfiles());
|
|
||||||
},
|
|
||||||
dispatchFetchLanguages() {
|
|
||||||
dispatch(fetchLanguages());
|
|
||||||
},
|
|
||||||
dispatchFetchIndexerFlags() {
|
|
||||||
dispatch(fetchIndexerFlags());
|
|
||||||
},
|
|
||||||
dispatchFetchImportLists() {
|
|
||||||
dispatch(fetchImportLists());
|
|
||||||
},
|
|
||||||
dispatchFetchUISettings() {
|
|
||||||
dispatch(fetchUISettings());
|
|
||||||
},
|
|
||||||
dispatchFetchStatus() {
|
|
||||||
dispatch(fetchStatus());
|
|
||||||
},
|
|
||||||
dispatchFetchTranslations() {
|
|
||||||
dispatch(fetchTranslations());
|
|
||||||
},
|
|
||||||
onResize(dimensions) {
|
|
||||||
dispatch(saveDimensions(dimensions));
|
|
||||||
},
|
|
||||||
onSidebarVisibleChange(isSidebarVisible) {
|
|
||||||
dispatch(setIsSidebarVisible({ isSidebarVisible }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class PageConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isLocalStorageSupported: testLocalStorage()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
if (!this.props.isPopulated) {
|
|
||||||
this.props.dispatchFetchMovies();
|
|
||||||
this.props.dispatchFetchMovieCollections();
|
|
||||||
this.props.dispatchFetchCustomFilters();
|
|
||||||
this.props.dispatchFetchTags();
|
|
||||||
this.props.dispatchFetchQualityProfiles();
|
|
||||||
this.props.dispatchFetchLanguages();
|
|
||||||
this.props.dispatchFetchIndexerFlags();
|
|
||||||
this.props.dispatchFetchImportLists();
|
|
||||||
this.props.dispatchFetchUISettings();
|
|
||||||
this.props.dispatchFetchStatus();
|
|
||||||
this.props.dispatchFetchTranslations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onSidebarToggle = () => {
|
|
||||||
this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
isPopulated,
|
|
||||||
hasError,
|
|
||||||
dispatchFetchMovies,
|
|
||||||
dispatchFetchMovieCollections,
|
|
||||||
dispatchFetchTags,
|
|
||||||
dispatchFetchQualityProfiles,
|
|
||||||
dispatchFetchLanguages,
|
|
||||||
dispatchFetchIndexerFlags,
|
|
||||||
dispatchFetchImportLists,
|
|
||||||
dispatchFetchUISettings,
|
|
||||||
dispatchFetchStatus,
|
|
||||||
dispatchFetchTranslations,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (hasError || !this.state.isLocalStorageSupported) {
|
|
||||||
return (
|
|
||||||
<ErrorPage
|
|
||||||
{...this.state}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPopulated) {
|
|
||||||
return (
|
|
||||||
<Page
|
|
||||||
{...otherProps}
|
|
||||||
onSidebarToggle={this.onSidebarToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingPage />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageConnector.propTypes = {
|
|
||||||
isPopulated: PropTypes.bool.isRequired,
|
|
||||||
hasError: PropTypes.bool.isRequired,
|
|
||||||
isSidebarVisible: PropTypes.bool.isRequired,
|
|
||||||
dispatchFetchMovies: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchMovieCollections: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchCustomFilters: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchTags: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchLanguages: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchIndexerFlags: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchImportLists: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchUISettings: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchStatus: PropTypes.func.isRequired,
|
|
||||||
dispatchFetchTranslations: PropTypes.func.isRequired,
|
|
||||||
onSidebarVisibleChange: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withRouter(
|
|
||||||
connect(createMapStateToProps, createMapDispatchToProps)(PageConnector)
|
|
||||||
);
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import DocumentTitle from 'react-document-title';
|
|
||||||
import ErrorBoundary from 'Components/Error/ErrorBoundary';
|
|
||||||
import PageContentError from './PageContentError';
|
|
||||||
import styles from './PageContent.css';
|
|
||||||
|
|
||||||
function PageContent(props) {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
children
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary errorComponent={PageContentError}>
|
|
||||||
<DocumentTitle title={title ? `${title} - ${window.Radarr.instanceName}` : window.Radarr.instanceName}>
|
|
||||||
<div className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</DocumentTitle>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PageContent.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
title: PropTypes.string,
|
|
||||||
children: PropTypes.node.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageContent.defaultProps = {
|
|
||||||
className: styles.content
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageContent;
|
|
||||||
33
frontend/src/Components/Page/PageContent.tsx
Normal file
33
frontend/src/Components/Page/PageContent.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React from 'react';
|
||||||
|
import DocumentTitle from 'react-document-title';
|
||||||
|
import ErrorBoundary from 'Components/Error/ErrorBoundary';
|
||||||
|
import PageContentError from './PageContentError';
|
||||||
|
import styles from './PageContent.css';
|
||||||
|
|
||||||
|
interface PageContentProps {
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageContent({
|
||||||
|
className = styles.content,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PageContentProps) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary errorComponent={PageContentError}>
|
||||||
|
<DocumentTitle
|
||||||
|
title={
|
||||||
|
title
|
||||||
|
? `${title} - ${window.Radarr.instanceName}`
|
||||||
|
: window.Radarr.instanceName
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
</DocumentTitle>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageContent;
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
|
import ErrorBoundaryError, {
|
||||||
|
ErrorBoundaryErrorProps,
|
||||||
|
} from 'Components/Error/ErrorBoundaryError';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import PageContentBody from './PageContentBody';
|
import PageContentBody from './PageContentBody';
|
||||||
import styles from './PageContentError.css';
|
import styles from './PageContentError.css';
|
||||||
|
|
||||||
function PageContentError(props) {
|
function PageContentError(props: ErrorBoundaryErrorProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<PageContentBody>
|
<PageContentBody>
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './PageContentFooter.css';
|
|
||||||
|
|
||||||
class PageContentFooter extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
children
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageContentFooter.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
children: PropTypes.node.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageContentFooter.defaultProps = {
|
|
||||||
className: styles.contentFooter
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageContentFooter;
|
|
||||||
16
frontend/src/Components/Page/PageContentFooter.tsx
Normal file
16
frontend/src/Components/Page/PageContentFooter.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './PageContentFooter.css';
|
||||||
|
|
||||||
|
interface PageContentFooterProps {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageContentFooter({
|
||||||
|
className = styles.contentFooter,
|
||||||
|
children,
|
||||||
|
}: PageContentFooterProps) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageContentFooter;
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Measure from 'Components/Measure';
|
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
|
||||||
import PageJumpBarItem from './PageJumpBarItem';
|
|
||||||
import styles from './PageJumpBar.css';
|
|
||||||
|
|
||||||
const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
|
|
||||||
|
|
||||||
class PageJumpBar extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
height: 0,
|
|
||||||
visibleItems: props.items.order
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.computeVisibleItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
|
||||||
return (
|
|
||||||
nextProps.items !== this.props.items ||
|
|
||||||
nextState.height !== this.state.height ||
|
|
||||||
nextState.visibleItems !== this.state.visibleItems
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
if (
|
|
||||||
prevProps.items !== this.props.items ||
|
|
||||||
prevState.height !== this.state.height
|
|
||||||
) {
|
|
||||||
this.computeVisibleItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
computeVisibleItems() {
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
minimumItems
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (!items) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
characters,
|
|
||||||
order
|
|
||||||
} = items;
|
|
||||||
|
|
||||||
const height = this.state.height;
|
|
||||||
const maximumItems = Math.floor(height / ITEM_HEIGHT);
|
|
||||||
const diff = order.length - maximumItems;
|
|
||||||
|
|
||||||
if (diff < 0) {
|
|
||||||
this.setState({ visibleItems: order });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.length < minimumItems) {
|
|
||||||
this.setState({ visibleItems: order });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get first, last, and most common in between to make up numbers
|
|
||||||
const visibleItems = [order[0]];
|
|
||||||
|
|
||||||
const sorted = order.slice(1, -1).map((x) => characters[x]).sort((a, b) => b - a);
|
|
||||||
const minCount = sorted[maximumItems - 3];
|
|
||||||
const greater = sorted.reduce((acc, value) => acc + (value > minCount ? 1 : 0), 0);
|
|
||||||
let minAllowed = maximumItems - 2 - greater;
|
|
||||||
|
|
||||||
for (let i = 1; i < order.length - 1; i++) {
|
|
||||||
if (characters[order[i]] > minCount) {
|
|
||||||
visibleItems.push(order[i]);
|
|
||||||
} else if (characters[order[i]] === minCount && minAllowed > 0) {
|
|
||||||
visibleItems.push(order[i]);
|
|
||||||
minAllowed--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleItems.push(order[order.length - 1]);
|
|
||||||
|
|
||||||
this.setState({ visibleItems });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onMeasure = ({ height }) => {
|
|
||||||
if (height > 0) {
|
|
||||||
this.setState({ height });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
minimumItems,
|
|
||||||
onItemPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
visibleItems
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
if (!visibleItems.length || visibleItems.length < minimumItems) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.jumpBar}>
|
|
||||||
<Measure
|
|
||||||
whitelist={['height']}
|
|
||||||
onMeasure={this.onMeasure}
|
|
||||||
>
|
|
||||||
<div className={styles.jumpBarItems}>
|
|
||||||
{
|
|
||||||
visibleItems.map((item) => {
|
|
||||||
return (
|
|
||||||
<PageJumpBarItem
|
|
||||||
key={item}
|
|
||||||
label={item}
|
|
||||||
onItemPress={onItemPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Measure>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageJumpBar.propTypes = {
|
|
||||||
items: PropTypes.object.isRequired,
|
|
||||||
minimumItems: PropTypes.number.isRequired,
|
|
||||||
onItemPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageJumpBar.defaultProps = {
|
|
||||||
minimumItems: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageJumpBar;
|
|
||||||
90
frontend/src/Components/Page/PageJumpBar.tsx
Normal file
90
frontend/src/Components/Page/PageJumpBar.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||||
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
import PageJumpBarItem, { PageJumpBarItemProps } from './PageJumpBarItem';
|
||||||
|
import styles from './PageJumpBar.css';
|
||||||
|
|
||||||
|
const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
|
||||||
|
|
||||||
|
export interface PageJumpBarItems {
|
||||||
|
characters: Record<string, number>;
|
||||||
|
order: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageJumpBarProps {
|
||||||
|
items: PageJumpBarItems;
|
||||||
|
minimumItems?: number;
|
||||||
|
onItemPress: PageJumpBarItemProps['onItemPress'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageJumpBar({
|
||||||
|
items,
|
||||||
|
minimumItems = 5,
|
||||||
|
onItemPress,
|
||||||
|
}: PageJumpBarProps) {
|
||||||
|
const [jumpBarRef, { height }] = useMeasure();
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
const { characters, order } = items;
|
||||||
|
|
||||||
|
const maximumItems = Math.floor(height / ITEM_HEIGHT);
|
||||||
|
const diff = order.length - maximumItems;
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.length < minimumItems) {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get first, last, and most common in between to make up numbers
|
||||||
|
const result = [order[0]];
|
||||||
|
|
||||||
|
const sorted = order
|
||||||
|
.slice(1, -1)
|
||||||
|
.map((x) => characters[x])
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
const minCount = sorted[maximumItems - 3];
|
||||||
|
const greater = sorted.reduce(
|
||||||
|
(acc, value) => acc + (value > minCount ? 1 : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let minAllowed = maximumItems - 2 - greater;
|
||||||
|
|
||||||
|
for (let i = 1; i < order.length - 1; i++) {
|
||||||
|
if (characters[order[i]] > minCount) {
|
||||||
|
result.push(order[i]);
|
||||||
|
} else if (characters[order[i]] === minCount && minAllowed > 0) {
|
||||||
|
result.push(order[i]);
|
||||||
|
minAllowed--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(order[order.length - 1]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [items, height, minimumItems]);
|
||||||
|
|
||||||
|
if (!items.order.length || items.order.length < minimumItems) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={jumpBarRef} className={styles.jumpBar}>
|
||||||
|
<div className={styles.jumpBarItems}>
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
return (
|
||||||
|
<PageJumpBarItem
|
||||||
|
key={item}
|
||||||
|
label={item}
|
||||||
|
onItemPress={onItemPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageJumpBar;
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import styles from './PageJumpBarItem.css';
|
|
||||||
|
|
||||||
class PageJumpBarItem extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onPress = () => {
|
|
||||||
const {
|
|
||||||
label,
|
|
||||||
onItemPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
onItemPress(label);
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={styles.jumpBarItem}
|
|
||||||
onPress={this.onPress}
|
|
||||||
>
|
|
||||||
{this.props.label.toUpperCase()}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageJumpBarItem.propTypes = {
|
|
||||||
label: PropTypes.string.isRequired,
|
|
||||||
onItemPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageJumpBarItem;
|
|
||||||
22
frontend/src/Components/Page/PageJumpBarItem.tsx
Normal file
22
frontend/src/Components/Page/PageJumpBarItem.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import styles from './PageJumpBarItem.css';
|
||||||
|
|
||||||
|
export interface PageJumpBarItemProps {
|
||||||
|
label: string;
|
||||||
|
onItemPress: (label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageJumpBarItem({ label, onItemPress }: PageJumpBarItemProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onItemPress(label);
|
||||||
|
}, [label, onItemPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={styles.jumpBarItem} onPress={handlePress}>
|
||||||
|
{label.toUpperCase()}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageJumpBarItem;
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
|
|
||||||
function PageSectionContent(props) {
|
|
||||||
const {
|
|
||||||
isFetching,
|
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
errorMessage,
|
|
||||||
children
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
if (isFetching) {
|
|
||||||
return (
|
|
||||||
<LoadingIndicator />
|
|
||||||
);
|
|
||||||
} else if (!isFetching && !!error) {
|
|
||||||
return (
|
|
||||||
<Alert kind={kinds.DANGER}>{errorMessage}</Alert>
|
|
||||||
);
|
|
||||||
} else if (isPopulated && !error) {
|
|
||||||
return (
|
|
||||||
<div>{children}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
PageSectionContent.propTypes = {
|
|
||||||
isFetching: PropTypes.bool.isRequired,
|
|
||||||
isPopulated: PropTypes.bool.isRequired,
|
|
||||||
error: PropTypes.object,
|
|
||||||
errorMessage: PropTypes.string.isRequired,
|
|
||||||
children: PropTypes.node.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSectionContent;
|
|
||||||
36
frontend/src/Components/Page/PageSectionContent.tsx
Normal file
36
frontend/src/Components/Page/PageSectionContent.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
|
||||||
|
interface PageSectionContentProps {
|
||||||
|
isFetching: boolean;
|
||||||
|
isPopulated: boolean;
|
||||||
|
error?: object;
|
||||||
|
errorMessage: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageSectionContent({
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
children,
|
||||||
|
}: PageSectionContentProps) {
|
||||||
|
if (isFetching) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFetching && !!error) {
|
||||||
|
return <Alert kind={kinds.DANGER}>{errorMessage}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPopulated && !error) {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageSectionContent;
|
||||||
|
|
@ -1,534 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
|
|
||||||
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
|
||||||
import Scroller from 'Components/Scroller/Scroller';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import locationShape from 'Helpers/Props/Shapes/locationShape';
|
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
|
||||||
import HealthStatus from 'System/Status/Health/HealthStatus';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import MessagesConnector from './Messages/MessagesConnector';
|
|
||||||
import PageSidebarItem from './PageSidebarItem';
|
|
||||||
import styles from './PageSidebar.css';
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = parseInt(dimensions.headerHeight);
|
|
||||||
const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{
|
|
||||||
iconName: icons.MOVIE_CONTINUING,
|
|
||||||
title: () => translate('Movies'),
|
|
||||||
to: '/',
|
|
||||||
alias: '/movies',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: () => translate('AddNew'),
|
|
||||||
to: '/add/new'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('ImportLibrary'),
|
|
||||||
to: '/add/import'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Collections'),
|
|
||||||
to: '/collections'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Discover'),
|
|
||||||
to: '/add/discover'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
iconName: icons.CALENDAR,
|
|
||||||
title: () => translate('Calendar'),
|
|
||||||
to: '/calendar'
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
iconName: icons.ACTIVITY,
|
|
||||||
title: () => translate('Activity'),
|
|
||||||
to: '/activity/queue',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: () => translate('Queue'),
|
|
||||||
to: '/activity/queue',
|
|
||||||
statusComponent: QueueStatus
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('History'),
|
|
||||||
to: '/activity/history'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Blocklist'),
|
|
||||||
to: '/activity/blocklist'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
iconName: icons.WARNING,
|
|
||||||
title: () => translate('Wanted'),
|
|
||||||
to: '/wanted/missing',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: () => translate('Missing'),
|
|
||||||
to: '/wanted/missing'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('CutoffUnmet'),
|
|
||||||
to: '/wanted/cutoffunmet'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
iconName: icons.SETTINGS,
|
|
||||||
title: () => translate('Settings'),
|
|
||||||
to: '/settings',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: () => translate('MediaManagement'),
|
|
||||||
to: '/settings/mediamanagement'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Profiles'),
|
|
||||||
to: '/settings/profiles'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Quality'),
|
|
||||||
to: '/settings/quality'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('CustomFormats'),
|
|
||||||
to: '/settings/customformats'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Indexers'),
|
|
||||||
to: '/settings/indexers'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('DownloadClients'),
|
|
||||||
to: '/settings/downloadclients'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('ImportLists'),
|
|
||||||
to: '/settings/importlists'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Connect'),
|
|
||||||
to: '/settings/connect'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Metadata'),
|
|
||||||
to: '/settings/metadata'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Tags'),
|
|
||||||
to: '/settings/tags'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('General'),
|
|
||||||
to: '/settings/general'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Ui'),
|
|
||||||
to: '/settings/ui'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
iconName: icons.SYSTEM,
|
|
||||||
title: () => translate('System'),
|
|
||||||
to: '/system/status',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: () => translate('Status'),
|
|
||||||
to: '/system/status',
|
|
||||||
statusComponent: HealthStatus
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Tasks'),
|
|
||||||
to: '/system/tasks'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Backup'),
|
|
||||||
to: '/system/backup'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Updates'),
|
|
||||||
to: '/system/updates'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('Events'),
|
|
||||||
to: '/system/events'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: () => translate('LogFiles'),
|
|
||||||
to: '/system/logs/files'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
function getActiveParent(pathname) {
|
|
||||||
let activeParent = links[0].to;
|
|
||||||
|
|
||||||
links.forEach((link) => {
|
|
||||||
if (link.to && link.to === pathname) {
|
|
||||||
activeParent = link.to;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const children = link.children;
|
|
||||||
|
|
||||||
if (children) {
|
|
||||||
children.forEach((childLink) => {
|
|
||||||
if (pathname.startsWith(childLink.to)) {
|
|
||||||
activeParent = link.to;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(link.to !== '/' && pathname.startsWith(link.to)) ||
|
|
||||||
(link.alias && pathname.startsWith(link.alias))
|
|
||||||
) {
|
|
||||||
activeParent = link.to;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return activeParent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasActiveChildLink(link, pathname) {
|
|
||||||
const children = link.children;
|
|
||||||
|
|
||||||
if (!children || !children.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _.some(children, (child) => {
|
|
||||||
return child.to === pathname;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPositioning() {
|
|
||||||
const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY;
|
|
||||||
const top = Math.max(HEADER_HEIGHT - windowScroll, 0);
|
|
||||||
const height = window.innerHeight - top;
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: `${top}px`,
|
|
||||||
height: `${height}px`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class PageSidebar extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this._touchStartX = null;
|
|
||||||
this._touchStartY = null;
|
|
||||||
this._sidebarRef = null;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
top: dimensions.headerHeight,
|
|
||||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
|
||||||
transition: null,
|
|
||||||
transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
if (this.props.isSmallScreen) {
|
|
||||||
window.addEventListener('click', this.onWindowClick, { capture: true });
|
|
||||||
window.addEventListener('scroll', this.onWindowScroll);
|
|
||||||
window.addEventListener('touchstart', this.onTouchStart);
|
|
||||||
window.addEventListener('touchmove', this.onTouchMove);
|
|
||||||
window.addEventListener('touchend', this.onTouchEnd);
|
|
||||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const {
|
|
||||||
isSidebarVisible
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const transform = this.state.transform;
|
|
||||||
|
|
||||||
if (prevProps.isSidebarVisible !== isSidebarVisible) {
|
|
||||||
this._setSidebarTransform(isSidebarVisible);
|
|
||||||
} else if (transform === 0 && !isSidebarVisible) {
|
|
||||||
this.props.onSidebarVisibleChange(true);
|
|
||||||
} else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) {
|
|
||||||
this.props.onSidebarVisibleChange(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this.props.isSmallScreen) {
|
|
||||||
window.removeEventListener('click', this.onWindowClick, { capture: true });
|
|
||||||
window.removeEventListener('scroll', this.onWindowScroll);
|
|
||||||
window.removeEventListener('touchstart', this.onTouchStart);
|
|
||||||
window.removeEventListener('touchmove', this.onTouchMove);
|
|
||||||
window.removeEventListener('touchend', this.onTouchEnd);
|
|
||||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
_setSidebarRef = (ref) => {
|
|
||||||
this._sidebarRef = ref;
|
|
||||||
};
|
|
||||||
|
|
||||||
_setSidebarTransform(isSidebarVisible, transition, callback) {
|
|
||||||
this.setState({
|
|
||||||
transition,
|
|
||||||
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
|
|
||||||
}, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onWindowClick = (event) => {
|
|
||||||
const sidebar = ReactDOM.findDOMNode(this._sidebarRef);
|
|
||||||
const toggleButton = document.getElementById('sidebar-toggle-button');
|
|
||||||
|
|
||||||
if (!sidebar) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!sidebar.contains(event.target) &&
|
|
||||||
!toggleButton.contains(event.target) &&
|
|
||||||
this.props.isSidebarVisible
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.props.onSidebarVisibleChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onWindowScroll = () => {
|
|
||||||
this.setState(getPositioning());
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchStart = (event) => {
|
|
||||||
const touches = event.touches;
|
|
||||||
const touchStartX = touches[0].pageX;
|
|
||||||
const touchStartY = touches[0].pageY;
|
|
||||||
const isSidebarVisible = this.props.isSidebarVisible;
|
|
||||||
|
|
||||||
if (touches.length !== 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) {
|
|
||||||
return;
|
|
||||||
} else if (!isSidebarVisible && touchStartX > 40) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._touchStartX = touchStartX;
|
|
||||||
this._touchStartY = touchStartY;
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchMove = (event) => {
|
|
||||||
const touches = event.touches;
|
|
||||||
const currentTouchX = touches[0].pageX;
|
|
||||||
// const currentTouchY = touches[0].pageY;
|
|
||||||
// const isSidebarVisible = this.props.isSidebarVisible;
|
|
||||||
|
|
||||||
if (!this._touchStartX) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a bit funky when trying to close and you scroll
|
|
||||||
// vertical too much by mistake, commenting out for now.
|
|
||||||
// TODO: Evaluate if this should be nuked
|
|
||||||
|
|
||||||
// if (Math.abs(this._touchStartY - currentTouchY) > 40) {
|
|
||||||
// const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1;
|
|
||||||
|
|
||||||
// this.setState({
|
|
||||||
// transition: 'none',
|
|
||||||
// transform
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (Math.abs(this._touchStartX - currentTouchX) < 40) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
transition: 'none',
|
|
||||||
transform
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchEnd = (event) => {
|
|
||||||
const touches = event.changedTouches;
|
|
||||||
const currentTouch = touches[0].pageX;
|
|
||||||
|
|
||||||
if (!this._touchStartX) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTouch > this._touchStartX && currentTouch > 50) {
|
|
||||||
this._setSidebarTransform(true, 'none');
|
|
||||||
} else if (currentTouch < this._touchStartX && currentTouch < 80) {
|
|
||||||
this._setSidebarTransform(false, 'transform 50ms ease-in-out');
|
|
||||||
} else {
|
|
||||||
this._setSidebarTransform(this.props.isSidebarVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._touchStartX = null;
|
|
||||||
this._touchStartY = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchCancel = (event) => {
|
|
||||||
this._touchStartX = null;
|
|
||||||
this._touchStartY = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
onItemPress = () => {
|
|
||||||
this.props.onSidebarVisibleChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
location,
|
|
||||||
isSmallScreen
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
top,
|
|
||||||
height,
|
|
||||||
transition,
|
|
||||||
transform
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const urlBase = window.Radarr.urlBase;
|
|
||||||
const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname;
|
|
||||||
const activeParent = getActiveParent(pathname);
|
|
||||||
|
|
||||||
let containerStyle = {};
|
|
||||||
let sidebarStyle = {};
|
|
||||||
|
|
||||||
if (isSmallScreen) {
|
|
||||||
containerStyle = {
|
|
||||||
transition,
|
|
||||||
transform: `translateX(${transform}px)`
|
|
||||||
};
|
|
||||||
|
|
||||||
sidebarStyle = {
|
|
||||||
top,
|
|
||||||
height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={this._setSidebarRef}
|
|
||||||
className={classNames(
|
|
||||||
styles.sidebarContainer
|
|
||||||
)}
|
|
||||||
style={containerStyle}
|
|
||||||
>
|
|
||||||
<ScrollerComponent
|
|
||||||
className={styles.sidebar}
|
|
||||||
style={sidebarStyle}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
links.map((link) => {
|
|
||||||
const childWithStatusComponent = _.find(link.children, (child) => {
|
|
||||||
return !!child.statusComponent;
|
|
||||||
});
|
|
||||||
|
|
||||||
const childStatusComponent = childWithStatusComponent ?
|
|
||||||
childWithStatusComponent.statusComponent :
|
|
||||||
null;
|
|
||||||
|
|
||||||
const isActiveParent = activeParent === link.to;
|
|
||||||
const hasActiveChild = hasActiveChildLink(link, pathname);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageSidebarItem
|
|
||||||
key={link.to}
|
|
||||||
iconName={link.iconName}
|
|
||||||
title={link.title}
|
|
||||||
to={link.to}
|
|
||||||
statusComponent={isActiveParent || !childStatusComponent ? link.statusComponent : childStatusComponent}
|
|
||||||
isActive={pathname === link.to && !hasActiveChild}
|
|
||||||
isActiveParent={isActiveParent}
|
|
||||||
isParentItem={!!link.children}
|
|
||||||
onPress={this.onItemPress}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
link.children && link.to === activeParent &&
|
|
||||||
link.children.map((child) => {
|
|
||||||
return (
|
|
||||||
<PageSidebarItem
|
|
||||||
key={child.to}
|
|
||||||
title={child.title}
|
|
||||||
to={child.to}
|
|
||||||
isActive={pathname.startsWith(child.to)}
|
|
||||||
isParentItem={false}
|
|
||||||
isChildItem={true}
|
|
||||||
statusComponent={child.statusComponent}
|
|
||||||
onPress={this.onItemPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</PageSidebarItem>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MessagesConnector />
|
|
||||||
</ScrollerComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageSidebar.propTypes = {
|
|
||||||
location: locationShape.isRequired,
|
|
||||||
isSmallScreen: PropTypes.bool.isRequired,
|
|
||||||
isSidebarVisible: PropTypes.bool.isRequired,
|
|
||||||
onSidebarVisibleChange: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSidebar;
|
|
||||||
524
frontend/src/Components/Page/Sidebar/PageSidebar.tsx
Normal file
524
frontend/src/Components/Page/Sidebar/PageSidebar.tsx
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
|
||||||
|
import { IconName } from 'Components/Icon';
|
||||||
|
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
||||||
|
import Scroller from 'Components/Scroller/Scroller';
|
||||||
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import { setIsSidebarVisible } from 'Store/Actions/appActions';
|
||||||
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
import HealthStatus from 'System/Status/Health/HealthStatus';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import MessagesConnector from './Messages/MessagesConnector';
|
||||||
|
import PageSidebarItem from './PageSidebarItem';
|
||||||
|
import styles from './PageSidebar.css';
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = parseInt(dimensions.headerHeight);
|
||||||
|
const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
|
||||||
|
|
||||||
|
interface SidebarItem {
|
||||||
|
iconName?: IconName;
|
||||||
|
title: string | (() => string);
|
||||||
|
to: string;
|
||||||
|
alias?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isActiveParent?: boolean;
|
||||||
|
isParentItem?: boolean;
|
||||||
|
isChildItem?: boolean;
|
||||||
|
statusComponent?: React.ElementType;
|
||||||
|
children?: {
|
||||||
|
title: string | (() => string);
|
||||||
|
to: string;
|
||||||
|
statusComponent?: React.ElementType;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINKS: SidebarItem[] = [
|
||||||
|
{
|
||||||
|
iconName: icons.MOVIE_CONTINUING,
|
||||||
|
title: () => translate('Movies'),
|
||||||
|
to: '/',
|
||||||
|
alias: '/movies',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: () => translate('AddNew'),
|
||||||
|
to: '/add/new',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('ImportLibrary'),
|
||||||
|
to: '/add/import',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Collections'),
|
||||||
|
to: '/collections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Discover'),
|
||||||
|
to: '/add/discover',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
iconName: icons.CALENDAR,
|
||||||
|
title: () => translate('Calendar'),
|
||||||
|
to: '/calendar',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
iconName: icons.ACTIVITY,
|
||||||
|
title: () => translate('Activity'),
|
||||||
|
to: '/activity/queue',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: () => translate('Queue'),
|
||||||
|
to: '/activity/queue',
|
||||||
|
statusComponent: QueueStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('History'),
|
||||||
|
to: '/activity/history',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Blocklist'),
|
||||||
|
to: '/activity/blocklist',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
iconName: icons.WARNING,
|
||||||
|
title: () => translate('Wanted'),
|
||||||
|
to: '/wanted/missing',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: () => translate('Missing'),
|
||||||
|
to: '/wanted/missing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('CutoffUnmet'),
|
||||||
|
to: '/wanted/cutoffunmet',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
iconName: icons.SETTINGS,
|
||||||
|
title: () => translate('Settings'),
|
||||||
|
to: '/settings',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: () => translate('MediaManagement'),
|
||||||
|
to: '/settings/mediamanagement',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Profiles'),
|
||||||
|
to: '/settings/profiles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Quality'),
|
||||||
|
to: '/settings/quality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('CustomFormats'),
|
||||||
|
to: '/settings/customformats',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Indexers'),
|
||||||
|
to: '/settings/indexers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('DownloadClients'),
|
||||||
|
to: '/settings/downloadclients',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('ImportLists'),
|
||||||
|
to: '/settings/importlists',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Connect'),
|
||||||
|
to: '/settings/connect',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Metadata'),
|
||||||
|
to: '/settings/metadata',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Tags'),
|
||||||
|
to: '/settings/tags',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('General'),
|
||||||
|
to: '/settings/general',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Ui'),
|
||||||
|
to: '/settings/ui',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
iconName: icons.SYSTEM,
|
||||||
|
title: () => translate('System'),
|
||||||
|
to: '/system/status',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: () => translate('Status'),
|
||||||
|
to: '/system/status',
|
||||||
|
statusComponent: HealthStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Tasks'),
|
||||||
|
to: '/system/tasks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Backup'),
|
||||||
|
to: '/system/backup',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Updates'),
|
||||||
|
to: '/system/updates',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('Events'),
|
||||||
|
to: '/system/events',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => translate('LogFiles'),
|
||||||
|
to: '/system/logs/files',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasActiveChildLink(link: SidebarItem, pathname: string) {
|
||||||
|
const children = link.children;
|
||||||
|
|
||||||
|
if (!children || !children.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children.some((child) => {
|
||||||
|
return child.to === pathname;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageSidebarProps {
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
isSidebarVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const location = useLocation();
|
||||||
|
const sidebarRef = useRef(null);
|
||||||
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const touchStartY = useRef<number | null>();
|
||||||
|
const wasSidebarVisible = usePrevious(isSidebarVisible);
|
||||||
|
|
||||||
|
const [sidebarTransform, setSidebarTransform] = useState<{
|
||||||
|
transition: string;
|
||||||
|
transform: number;
|
||||||
|
}>({
|
||||||
|
transition: 'none',
|
||||||
|
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
||||||
|
});
|
||||||
|
const [sidebarStyle, setSidebarStyle] = useState({
|
||||||
|
top: dimensions.headerHeight,
|
||||||
|
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlBase = window.Radarr.urlBase;
|
||||||
|
const pathname = urlBase
|
||||||
|
? location.pathname.substr(urlBase.length) || '/'
|
||||||
|
: location.pathname;
|
||||||
|
|
||||||
|
const activeParent = useMemo(() => {
|
||||||
|
return (
|
||||||
|
LINKS.find((link) => {
|
||||||
|
if (link.to && link.to === pathname) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = link.children;
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
const matchingChild = children.find((childLink) => {
|
||||||
|
return pathname.startsWith(childLink.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingChild) {
|
||||||
|
return matchingChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(link.to !== '/' && pathname.startsWith(link.to)) ||
|
||||||
|
(link.alias && pathname.startsWith(link.alias))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})?.to ?? LINKS[0].to
|
||||||
|
);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const handleWindowClick = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
const sidebar = ReactDOM.findDOMNode(sidebarRef.current);
|
||||||
|
const toggleButton = document.getElementById('sidebar-toggle-button');
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (!sidebar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
target instanceof Node &&
|
||||||
|
!sidebar.contains(target) &&
|
||||||
|
!toggleButton?.contains(target) &&
|
||||||
|
isSidebarVisible
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isSidebarVisible, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(() => {
|
||||||
|
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleWindowScroll = useCallback(() => {
|
||||||
|
const windowScroll =
|
||||||
|
window.scrollY == null
|
||||||
|
? document.documentElement.scrollTop
|
||||||
|
: window.scrollY;
|
||||||
|
const sidebarTop = Math.max(HEADER_HEIGHT - windowScroll, 0);
|
||||||
|
const sidebarHeight = window.innerHeight - sidebarTop;
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
setSidebarStyle({
|
||||||
|
top: `${sidebarTop}px`,
|
||||||
|
height: `${sidebarHeight}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isSmallScreen]);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback(
|
||||||
|
(event: TouchEvent) => {
|
||||||
|
const touches = event.touches;
|
||||||
|
const x = touches[0].pageX;
|
||||||
|
const y = touches[0].pageY;
|
||||||
|
|
||||||
|
if (touches.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSidebarVisible && (x > 210 || x < 180)) {
|
||||||
|
return;
|
||||||
|
} else if (!isSidebarVisible && x > 40) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStartX.current = x;
|
||||||
|
touchStartY.current = y;
|
||||||
|
},
|
||||||
|
[isSidebarVisible]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((event: TouchEvent) => {
|
||||||
|
const touches = event.touches;
|
||||||
|
const currentTouchX = touches[0].pageX;
|
||||||
|
// const currentTouchY = touches[0].pageY;
|
||||||
|
// const isSidebarVisible = this.props.isSidebarVisible;
|
||||||
|
|
||||||
|
if (!touchStartX.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(touchStartX.current - currentTouchX) < 40) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0);
|
||||||
|
|
||||||
|
setSidebarTransform({
|
||||||
|
transition: 'none',
|
||||||
|
transform,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback((event: TouchEvent) => {
|
||||||
|
const touches = event.changedTouches;
|
||||||
|
const currentTouch = touches[0].pageX;
|
||||||
|
|
||||||
|
if (!touchStartX.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTouch > touchStartX.current && currentTouch > 50) {
|
||||||
|
setSidebarTransform({
|
||||||
|
transition: 'none',
|
||||||
|
transform: 0,
|
||||||
|
});
|
||||||
|
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
|
||||||
|
setSidebarTransform({
|
||||||
|
transition: 'transform 50ms ease-in-out',
|
||||||
|
transform: SIDEBAR_WIDTH * -1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSidebarTransform({
|
||||||
|
transition: 'none',
|
||||||
|
transform: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStartX.current = null;
|
||||||
|
touchStartY.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchCancel = useCallback(() => {
|
||||||
|
touchStartX.current = null;
|
||||||
|
touchStartY.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
window.addEventListener('click', handleWindowClick, { capture: true });
|
||||||
|
window.addEventListener('scroll', handleWindowScroll);
|
||||||
|
window.addEventListener('touchstart', handleTouchStart);
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
window.addEventListener('touchcancel', handleTouchCancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', handleWindowClick, { capture: true });
|
||||||
|
window.removeEventListener('scroll', handleWindowScroll);
|
||||||
|
window.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
window.removeEventListener('touchcancel', handleTouchCancel);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isSmallScreen,
|
||||||
|
handleWindowClick,
|
||||||
|
handleWindowScroll,
|
||||||
|
handleTouchStart,
|
||||||
|
handleTouchMove,
|
||||||
|
handleTouchEnd,
|
||||||
|
handleTouchCancel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasSidebarVisible !== isSidebarVisible) {
|
||||||
|
setSidebarTransform({
|
||||||
|
transition: 'none',
|
||||||
|
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
||||||
|
});
|
||||||
|
} else if (sidebarTransform.transform === 0 && !isSidebarVisible) {
|
||||||
|
dispatch(setIsSidebarVisible({ isSidebarVisible: true }));
|
||||||
|
} else if (
|
||||||
|
sidebarTransform.transform === -SIDEBAR_WIDTH &&
|
||||||
|
isSidebarVisible
|
||||||
|
) {
|
||||||
|
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||||
|
}
|
||||||
|
}, [sidebarTransform, isSidebarVisible, wasSidebarVisible, dispatch]);
|
||||||
|
|
||||||
|
const containerStyle = useMemo(() => {
|
||||||
|
if (!isSmallScreen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transition: sidebarTransform.transition ?? 'none',
|
||||||
|
transform: `translateX(${sidebarTransform.transform}px)`,
|
||||||
|
};
|
||||||
|
}, [isSmallScreen, sidebarTransform]);
|
||||||
|
|
||||||
|
const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={classNames(styles.sidebarContainer)}
|
||||||
|
style={containerStyle}
|
||||||
|
>
|
||||||
|
<ScrollerComponent
|
||||||
|
className={styles.sidebar}
|
||||||
|
scrollDirection="vertical"
|
||||||
|
style={sidebarStyle}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{LINKS.map((link) => {
|
||||||
|
const childWithStatusComponent = link.children?.find((child) => {
|
||||||
|
return !!child.statusComponent;
|
||||||
|
});
|
||||||
|
|
||||||
|
const childStatusComponent = childWithStatusComponent
|
||||||
|
? childWithStatusComponent.statusComponent
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isActiveParent = activeParent === link.to;
|
||||||
|
const hasActiveChild = hasActiveChildLink(link, pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSidebarItem
|
||||||
|
key={link.to}
|
||||||
|
iconName={link.iconName}
|
||||||
|
title={link.title}
|
||||||
|
to={link.to}
|
||||||
|
statusComponent={
|
||||||
|
isActiveParent || !childStatusComponent
|
||||||
|
? link.statusComponent
|
||||||
|
: childStatusComponent
|
||||||
|
}
|
||||||
|
isActive={pathname === link.to && !hasActiveChild}
|
||||||
|
isActiveParent={isActiveParent}
|
||||||
|
isParentItem={!!link.children}
|
||||||
|
onPress={handleItemPress}
|
||||||
|
>
|
||||||
|
{link.children &&
|
||||||
|
link.to === activeParent &&
|
||||||
|
link.children.map((child) => {
|
||||||
|
return (
|
||||||
|
<PageSidebarItem
|
||||||
|
key={child.to}
|
||||||
|
title={child.title}
|
||||||
|
to={child.to}
|
||||||
|
isActive={pathname === child.to}
|
||||||
|
isParentItem={false}
|
||||||
|
isChildItem={true}
|
||||||
|
statusComponent={child.statusComponent}
|
||||||
|
onPress={handleItemPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PageSidebarItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessagesConnector />
|
||||||
|
</ScrollerComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageSidebar;
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import { map } from 'Helpers/elementChildren';
|
|
||||||
import styles from './PageSidebarItem.css';
|
|
||||||
|
|
||||||
class PageSidebarItem extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onPress = () => {
|
|
||||||
const {
|
|
||||||
isChildItem,
|
|
||||||
isParentItem,
|
|
||||||
onPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (isChildItem || !isParentItem) {
|
|
||||||
onPress();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
iconName,
|
|
||||||
title,
|
|
||||||
to,
|
|
||||||
isActive,
|
|
||||||
isActiveParent,
|
|
||||||
isChildItem,
|
|
||||||
statusComponent: StatusComponent,
|
|
||||||
children
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.item,
|
|
||||||
isActiveParent && styles.isActiveItem
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className={classNames(
|
|
||||||
isChildItem ? styles.childLink : styles.link,
|
|
||||||
isActiveParent && styles.isActiveParentLink,
|
|
||||||
isActive && styles.isActiveLink
|
|
||||||
)}
|
|
||||||
to={to}
|
|
||||||
onPress={this.onPress}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
!!iconName &&
|
|
||||||
<span className={styles.iconContainer}>
|
|
||||||
<Icon
|
|
||||||
name={iconName}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
{typeof title === 'function' ? title() : title}
|
|
||||||
|
|
||||||
{
|
|
||||||
!!StatusComponent &&
|
|
||||||
<span className={styles.status}>
|
|
||||||
<StatusComponent />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{
|
|
||||||
children &&
|
|
||||||
map(children, (child) => {
|
|
||||||
return React.cloneElement(child, { isChildItem: true });
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageSidebarItem.propTypes = {
|
|
||||||
iconName: PropTypes.object,
|
|
||||||
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
|
|
||||||
to: PropTypes.string.isRequired,
|
|
||||||
isActive: PropTypes.bool,
|
|
||||||
isActiveParent: PropTypes.bool,
|
|
||||||
isParentItem: PropTypes.bool.isRequired,
|
|
||||||
isChildItem: PropTypes.bool.isRequired,
|
|
||||||
statusComponent: PropTypes.elementType,
|
|
||||||
children: PropTypes.node,
|
|
||||||
onPress: PropTypes.func
|
|
||||||
};
|
|
||||||
|
|
||||||
PageSidebarItem.defaultProps = {
|
|
||||||
isChildItem: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSidebarItem;
|
|
||||||
81
frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
Normal file
81
frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { Children, useCallback } from 'react';
|
||||||
|
import Icon, { IconName } from 'Components/Icon';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import styles from './PageSidebarItem.css';
|
||||||
|
|
||||||
|
export interface PageSidebarItemProps {
|
||||||
|
iconName?: IconName;
|
||||||
|
title: string | (() => string);
|
||||||
|
to: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isActiveParent?: boolean;
|
||||||
|
isParentItem?: boolean;
|
||||||
|
isChildItem?: boolean;
|
||||||
|
statusComponent?: React.ElementType;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageSidebarItem({
|
||||||
|
iconName,
|
||||||
|
title,
|
||||||
|
to,
|
||||||
|
isActive,
|
||||||
|
isActiveParent,
|
||||||
|
isChildItem = false,
|
||||||
|
isParentItem = false,
|
||||||
|
statusComponent: StatusComponent,
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
}: PageSidebarItemProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (isChildItem || !isParentItem) {
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
}, [isChildItem, isParentItem, onPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.item, isActiveParent && styles.isActiveItem)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={classNames(
|
||||||
|
isChildItem ? styles.childLink : styles.link,
|
||||||
|
isActiveParent && styles.isActiveParentLink,
|
||||||
|
isActive && styles.isActiveLink
|
||||||
|
)}
|
||||||
|
to={to}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
{!!iconName && (
|
||||||
|
<span className={styles.iconContainer}>
|
||||||
|
<Icon name={iconName} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof title === 'function' ? title() : title}
|
||||||
|
|
||||||
|
{!!StatusComponent && (
|
||||||
|
<span className={styles.status}>
|
||||||
|
<StatusComponent />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{children
|
||||||
|
? Children.map(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childProps = { isChildItem: true };
|
||||||
|
|
||||||
|
return React.cloneElement(child, childProps);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageSidebarItem;
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Label from 'Components/Label';
|
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
|
||||||
|
|
||||||
function PageSidebarStatus({ count, errors, warnings }) {
|
|
||||||
if (!count) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let kind = kinds.INFO;
|
|
||||||
|
|
||||||
if (errors) {
|
|
||||||
kind = kinds.DANGER;
|
|
||||||
} else if (warnings) {
|
|
||||||
kind = kinds.WARNING;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
kind={kind}
|
|
||||||
size={sizes.MEDIUM}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PageSidebarStatus.propTypes = {
|
|
||||||
count: PropTypes.number,
|
|
||||||
errors: PropTypes.bool,
|
|
||||||
warnings: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSidebarStatus;
|
|
||||||
35
frontend/src/Components/Page/Sidebar/PageSidebarStatus.tsx
Normal file
35
frontend/src/Components/Page/Sidebar/PageSidebarStatus.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
|
||||||
|
interface PageSidebarStatusProps {
|
||||||
|
count?: number;
|
||||||
|
errors?: boolean;
|
||||||
|
warnings?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageSidebarStatus({
|
||||||
|
count,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
}: PageSidebarStatusProps) {
|
||||||
|
if (!count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: Kind = 'info';
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
kind = 'danger';
|
||||||
|
} else if (warnings) {
|
||||||
|
kind = 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label kind={kind} size="medium">
|
||||||
|
{count}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageSidebarStatus;
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './PageToolbar.css';
|
|
||||||
|
|
||||||
class PageToolbar extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
children
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageToolbar.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
children: PropTypes.node.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageToolbar.defaultProps = {
|
|
||||||
className: styles.toolbar
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageToolbar;
|
|
||||||
16
frontend/src/Components/Page/Toolbar/PageToolbar.tsx
Normal file
16
frontend/src/Components/Page/Toolbar/PageToolbar.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './PageToolbar.css';
|
||||||
|
|
||||||
|
interface PageToolbarProps {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageToolbar({
|
||||||
|
className = styles.toolbar,
|
||||||
|
children,
|
||||||
|
}: PageToolbarProps) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageToolbar;
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
import React, { SyntheticEvent } from 'react';
|
||||||
import React from 'react';
|
import { IconName } from 'Components/Icon';
|
||||||
import MenuItem from 'Components/Menu/MenuItem';
|
import MenuItem from 'Components/Menu/MenuItem';
|
||||||
import SpinnerIcon from 'Components/SpinnerIcon';
|
import SpinnerIcon from 'Components/SpinnerIcon';
|
||||||
import styles from './PageToolbarOverflowMenuItem.css';
|
import styles from './PageToolbarOverflowMenuItem.css';
|
||||||
|
|
||||||
interface PageToolbarOverflowMenuItemProps {
|
interface PageToolbarOverflowMenuItemProps {
|
||||||
iconName: IconDefinition;
|
iconName: IconName;
|
||||||
spinningName?: IconDefinition;
|
spinningName?: IconName;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
isSpinning?: boolean;
|
isSpinning?: boolean;
|
||||||
showIndicator?: boolean;
|
showIndicator?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
onPress: () => void;
|
onPress?: (event: SyntheticEvent<Element, Event>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) {
|
function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) {
|
||||||
|
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Measure from 'Components/Measure';
|
|
||||||
import Menu from 'Components/Menu/Menu';
|
|
||||||
import MenuContent from 'Components/Menu/MenuContent';
|
|
||||||
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
|
||||||
import { forEach } from 'Helpers/elementChildren';
|
|
||||||
import { align, icons } from 'Helpers/Props';
|
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem';
|
|
||||||
import styles from './PageToolbarSection.css';
|
|
||||||
|
|
||||||
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
|
|
||||||
const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
|
|
||||||
const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
|
|
||||||
|
|
||||||
function calculateOverflowItems(children, isMeasured, width, collapseButtons) {
|
|
||||||
let buttonCount = 0;
|
|
||||||
let separatorCount = 0;
|
|
||||||
const validChildren = [];
|
|
||||||
|
|
||||||
forEach(children, (child) => {
|
|
||||||
if (Object.keys(child.props).length === 0) {
|
|
||||||
separatorCount++;
|
|
||||||
} else {
|
|
||||||
buttonCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
validChildren.push(child);
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonsWidth = buttonCount * BUTTON_WIDTH;
|
|
||||||
const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
|
|
||||||
const totalWidth = buttonsWidth + separatorsWidth;
|
|
||||||
|
|
||||||
// If the width of buttons and separators is less than
|
|
||||||
// the available width return all valid children.
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isMeasured ||
|
|
||||||
!collapseButtons ||
|
|
||||||
totalWidth < width
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
buttons: validChildren,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1);
|
|
||||||
const buttons = [];
|
|
||||||
const overflowItems = [];
|
|
||||||
let actualButtons = 0;
|
|
||||||
|
|
||||||
// Return all buttons if only one is being pushed to the overflow menu.
|
|
||||||
if (buttonCount - 1 === maxButtons) {
|
|
||||||
return {
|
|
||||||
buttons: validChildren,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
validChildren.forEach((child, index) => {
|
|
||||||
const isSeparator = Object.keys(child.props).length === 0;
|
|
||||||
|
|
||||||
if (actualButtons < maxButtons) {
|
|
||||||
if (!isSeparator) {
|
|
||||||
buttons.push(child);
|
|
||||||
actualButtons++;
|
|
||||||
}
|
|
||||||
} else if (!isSeparator) {
|
|
||||||
overflowItems.push(child.props);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
buttons,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class PageToolbarSection extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isMeasured: false,
|
|
||||||
width: 0,
|
|
||||||
buttons: [],
|
|
||||||
overflowItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onMeasure = ({ width }) => {
|
|
||||||
this.setState({
|
|
||||||
isMeasured: true,
|
|
||||||
width
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
children,
|
|
||||||
alignContent,
|
|
||||||
collapseButtons
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
isMeasured,
|
|
||||||
width
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const {
|
|
||||||
buttons,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems
|
|
||||||
} = calculateOverflowItems(children, isMeasured, width, collapseButtons);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Measure
|
|
||||||
whitelist={['width']}
|
|
||||||
onMeasure={this.onMeasure}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={styles.sectionContainer}
|
|
||||||
style={{
|
|
||||||
flexGrow: buttonCount
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
isMeasured ?
|
|
||||||
<div className={classNames(
|
|
||||||
styles.section,
|
|
||||||
styles[alignContent]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
buttons.map((button) => {
|
|
||||||
return button;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!!overflowItems.length &&
|
|
||||||
<Menu>
|
|
||||||
<ToolbarMenuButton
|
|
||||||
className={styles.overflowMenuButton}
|
|
||||||
iconName={icons.OVERFLOW}
|
|
||||||
text={translate('More')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MenuContent>
|
|
||||||
{
|
|
||||||
overflowItems.map((item) => {
|
|
||||||
const {
|
|
||||||
label,
|
|
||||||
overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem
|
|
||||||
} = item;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverflowComponent
|
|
||||||
key={label}
|
|
||||||
{...item}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</MenuContent>
|
|
||||||
</Menu>
|
|
||||||
}
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Measure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
PageToolbarSection.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]),
|
|
||||||
collapseButtons: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageToolbarSection.defaultProps = {
|
|
||||||
alignContent: align.LEFT,
|
|
||||||
collapseButtons: true
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageToolbarSection;
|
|
||||||
151
frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx
Normal file
151
frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { ReactElement, useMemo } from 'react';
|
||||||
|
import Menu from 'Components/Menu/Menu';
|
||||||
|
import MenuContent from 'Components/Menu/MenuContent';
|
||||||
|
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
||||||
|
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import { Align } from 'Helpers/Props/align';
|
||||||
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import { PageToolbarButtonProps } from './PageToolbarButton';
|
||||||
|
import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem';
|
||||||
|
import styles from './PageToolbarSection.css';
|
||||||
|
|
||||||
|
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
|
||||||
|
const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
|
||||||
|
const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
|
||||||
|
|
||||||
|
interface PageToolbarSectionProps {
|
||||||
|
children?:
|
||||||
|
| (ReactElement<PageToolbarButtonProps> | ReactElement<never>)
|
||||||
|
| (ReactElement<PageToolbarButtonProps> | ReactElement<never>)[];
|
||||||
|
alignContent?: Extract<Align, keyof typeof styles>;
|
||||||
|
collapseButtons?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageToolbarSection({
|
||||||
|
children,
|
||||||
|
alignContent = 'left',
|
||||||
|
collapseButtons = true,
|
||||||
|
}: PageToolbarSectionProps) {
|
||||||
|
const [sectionRef, { width }] = useMeasure();
|
||||||
|
const isMeasured = width > 0;
|
||||||
|
|
||||||
|
const { buttons, buttonCount, overflowItems } = useMemo(() => {
|
||||||
|
let buttonCount = 0;
|
||||||
|
let separatorCount = 0;
|
||||||
|
const validChildren: ReactElement[] = [];
|
||||||
|
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!child) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(child.props).length === 0) {
|
||||||
|
separatorCount++;
|
||||||
|
} else {
|
||||||
|
buttonCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
validChildren.push(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonsWidth = buttonCount * BUTTON_WIDTH;
|
||||||
|
const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
|
||||||
|
const totalWidth = buttonsWidth + separatorsWidth;
|
||||||
|
|
||||||
|
// If the width of buttons and separators is less than
|
||||||
|
// the available width return all valid children.
|
||||||
|
|
||||||
|
if (!isMeasured || !collapseButtons || totalWidth < width) {
|
||||||
|
return {
|
||||||
|
buttons: validChildren,
|
||||||
|
buttonCount,
|
||||||
|
overflowItems: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxButtons = Math.max(
|
||||||
|
Math.floor((width - separatorsWidth) / BUTTON_WIDTH),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons: ReactElement<PageToolbarButtonProps>[] = [];
|
||||||
|
const overflowItems: PageToolbarButtonProps[] = [];
|
||||||
|
|
||||||
|
let actualButtons = 0;
|
||||||
|
|
||||||
|
// Return all buttons if only one is being pushed to the overflow menu.
|
||||||
|
if (buttonCount - 1 === maxButtons) {
|
||||||
|
const overflowItems: PageToolbarButtonProps[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttons: validChildren,
|
||||||
|
buttonCount,
|
||||||
|
overflowItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validChildren.forEach((child) => {
|
||||||
|
const isSeparator = Object.keys(child.props).length === 0;
|
||||||
|
|
||||||
|
if (actualButtons < maxButtons) {
|
||||||
|
if (!isSeparator) {
|
||||||
|
buttons.push(child);
|
||||||
|
actualButtons++;
|
||||||
|
}
|
||||||
|
} else if (!isSeparator) {
|
||||||
|
overflowItems.push(child.props);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttons,
|
||||||
|
buttonCount,
|
||||||
|
overflowItems,
|
||||||
|
};
|
||||||
|
}, [children, isMeasured, width, collapseButtons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sectionRef}
|
||||||
|
className={styles.sectionContainer}
|
||||||
|
style={{
|
||||||
|
flexGrow: buttonCount,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMeasured ? (
|
||||||
|
<div className={classNames(styles.section, styles[alignContent])}>
|
||||||
|
{buttons.map((button) => {
|
||||||
|
return button;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{overflowItems.length ? (
|
||||||
|
<Menu>
|
||||||
|
<ToolbarMenuButton
|
||||||
|
className={styles.overflowMenuButton}
|
||||||
|
iconName={icons.OVERFLOW}
|
||||||
|
text={translate('More')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MenuContent>
|
||||||
|
{overflowItems.map((item) => {
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
overflowComponent:
|
||||||
|
OverflowComponent = PageToolbarOverflowMenuItem,
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
return <OverflowComponent key={label} {...item} />;
|
||||||
|
})}
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageToolbarSection;
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './PageToolbarSeparator.css';
|
|
||||||
|
|
||||||
class PageToolbarSeparator extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className={styles.separator} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PageToolbarSeparator;
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './PageToolbarSeparator.css';
|
||||||
|
|
||||||
|
function PageToolbarSeparator() {
|
||||||
|
return <div className={styles.separator} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageToolbarSeparator;
|
||||||
|
|
@ -11,8 +11,8 @@ interface OverlayScrollerProps {
|
||||||
trackClassName?: string;
|
trackClassName?: string;
|
||||||
scrollTop?: number;
|
scrollTop?: number;
|
||||||
scrollDirection: ScrollDirection;
|
scrollDirection: ScrollDirection;
|
||||||
autoHide: boolean;
|
autoHide?: boolean;
|
||||||
autoScroll: boolean;
|
autoScroll?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onScroll?: (payload: OnScroll) => void;
|
onScroll?: (payload: OnScroll) => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import Icon, { IconProps } from './Icon';
|
import Icon, { IconName, IconProps } from './Icon';
|
||||||
|
|
||||||
export interface SpinnerIconProps extends IconProps {
|
export interface SpinnerIconProps extends IconProps {
|
||||||
spinningName?: IconProps['name'];
|
spinningName?: IconName;
|
||||||
isSpinning: Required<IconProps['isSpinning']>;
|
isSpinning: Required<IconProps['isSpinning']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,99 @@
|
||||||
import Mousetrap from 'mousetrap';
|
import Mousetrap, { MousetrapInstance } from 'mousetrap';
|
||||||
import React, { Component } from 'react';
|
import React, { Component, ComponentType } from 'react';
|
||||||
import getDisplayName from 'Helpers/getDisplayName';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
export const shortcuts = {
|
export interface Shortcut {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BindingOptions {
|
||||||
|
isGlobal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyboardShortcutsProps {
|
||||||
|
bindShortcut: (
|
||||||
|
key: string,
|
||||||
|
callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void,
|
||||||
|
options?: BindingOptions
|
||||||
|
) => void;
|
||||||
|
unbindShortcut: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shortcuts: Record<string, Shortcut> = {
|
||||||
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
|
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
|
||||||
key: '?',
|
key: '?',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsOpenModal');
|
return translate('KeyboardShortcutsOpenModal');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
CLOSE_MODAL: {
|
CLOSE_MODAL: {
|
||||||
key: 'Esc',
|
key: 'Esc',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsCloseModal');
|
return translate('KeyboardShortcutsCloseModal');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
ACCEPT_CONFIRM_MODAL: {
|
ACCEPT_CONFIRM_MODAL: {
|
||||||
key: 'Enter',
|
key: 'Enter',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsConfirmModal');
|
return translate('KeyboardShortcutsConfirmModal');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
MOVIE_SEARCH_INPUT: {
|
MOVIE_SEARCH_INPUT: {
|
||||||
key: 's',
|
key: 's',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsFocusSearchBox');
|
return translate('KeyboardShortcutsFocusSearchBox');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
SAVE_SETTINGS: {
|
SAVE_SETTINGS: {
|
||||||
key: 'mod+s',
|
key: 'mod+s',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsSaveSettings');
|
return translate('KeyboardShortcutsSaveSettings');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
SCROLL_TOP: {
|
SCROLL_TOP: {
|
||||||
key: 'mod+home',
|
key: 'mod+home',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsMovieIndexScrollTop');
|
return translate('KeyboardShortcutsMovieIndexScrollTop');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
SCROLL_BOTTOM: {
|
SCROLL_BOTTOM: {
|
||||||
key: 'mod+end',
|
key: 'mod+end',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsMovieIndexScrollBottom');
|
return translate('KeyboardShortcutsMovieIndexScrollBottom');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
DETAILS_NEXT: {
|
DETAILS_NEXT: {
|
||||||
key: '→',
|
key: '→',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsMovieDetailsNextMovie');
|
return translate('KeyboardShortcutsMovieDetailsNextMovie');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
DETAILS_PREVIOUS: {
|
DETAILS_PREVIOUS: {
|
||||||
key: '←',
|
key: '←',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
|
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function keyboardShortcuts(WrappedComponent) {
|
function keyboardShortcuts(
|
||||||
|
WrappedComponent: ComponentType<KeyboardShortcutsProps>
|
||||||
|
) {
|
||||||
class KeyboardShortcuts extends Component {
|
class KeyboardShortcuts extends Component {
|
||||||
|
|
||||||
//
|
//
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props: never) {
|
||||||
super(props, context);
|
super(props);
|
||||||
this._mousetrapBindings = {};
|
this._mousetrapBindings = {};
|
||||||
this._mousetrap = new Mousetrap();
|
this._mousetrap = new Mousetrap();
|
||||||
this._mousetrap.stopCallback = this.stopCallback;
|
this._mousetrap.stopCallback = this.stopCallback;
|
||||||
|
|
@ -86,15 +104,22 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
this._mousetrap = null;
|
this._mousetrap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_mousetrap: MousetrapInstance | null;
|
||||||
|
_mousetrapBindings: Record<string, BindingOptions>;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Control
|
// Control
|
||||||
|
|
||||||
bindShortcut = (key, callback, options = {}) => {
|
bindShortcut = (
|
||||||
this._mousetrap.bind(key, callback);
|
key: string,
|
||||||
|
callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void,
|
||||||
|
options: BindingOptions = {}
|
||||||
|
) => {
|
||||||
|
this._mousetrap?.bind(key, callback);
|
||||||
this._mousetrapBindings[key] = options;
|
this._mousetrapBindings[key] = options;
|
||||||
};
|
};
|
||||||
|
|
||||||
unbindShortcut = (key) => {
|
unbindShortcut = (key: string) => {
|
||||||
if (this._mousetrap != null) {
|
if (this._mousetrap != null) {
|
||||||
delete this._mousetrapBindings[key];
|
delete this._mousetrapBindings[key];
|
||||||
this._mousetrap.unbind(key);
|
this._mousetrap.unbind(key);
|
||||||
|
|
@ -109,13 +134,17 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
keys.forEach((binding) => {
|
keys.forEach((binding) => {
|
||||||
this._mousetrap.unbind(binding);
|
this._mousetrap?.unbind(binding);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._mousetrapBindings = {};
|
this._mousetrapBindings = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
stopCallback = (event, element, combo) => {
|
stopCallback = (
|
||||||
|
_e: Mousetrap.ExtendedKeyboardEvent,
|
||||||
|
element: Element,
|
||||||
|
combo: string
|
||||||
|
) => {
|
||||||
const binding = this._mousetrapBindings[combo];
|
const binding = this._mousetrapBindings[combo];
|
||||||
|
|
||||||
if (!binding || binding.isGlobal) {
|
if (!binding || binding.isGlobal) {
|
||||||
|
|
@ -126,7 +155,7 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
element.tagName === 'INPUT' ||
|
element.tagName === 'INPUT' ||
|
||||||
element.tagName === 'SELECT' ||
|
element.tagName === 'SELECT' ||
|
||||||
element.tagName === 'TEXTAREA' ||
|
element.tagName === 'TEXTAREA' ||
|
||||||
(element.contentEditable && element.contentEditable === 'true')
|
('contentEditable' in element && element.contentEditable === 'true')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -144,9 +173,6 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
|
|
||||||
KeyboardShortcuts.WrappedComponent = WrappedComponent;
|
|
||||||
|
|
||||||
return KeyboardShortcuts;
|
return KeyboardShortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon, { IconProps } from 'Components/Icon';
|
import Icon, { IconName } from 'Components/Icon';
|
||||||
import styles from './DiscoverMovieOverviewInfoRow.css';
|
import styles from './DiscoverMovieOverviewInfoRow.css';
|
||||||
|
|
||||||
interface DiscoverMovieOverviewInfoRowProps {
|
interface DiscoverMovieOverviewInfoRowProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
iconName: IconProps['name'];
|
iconName: IconName;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
130
frontend/src/Helpers/Hooks/useAppPage.ts
Normal file
130
frontend/src/Helpers/Hooks/useAppPage.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import { fetchTranslations } from 'Store/Actions/appActions';
|
||||||
|
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
|
||||||
|
import { fetchMovies } from 'Store/Actions/movieActions';
|
||||||
|
import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions';
|
||||||
|
import {
|
||||||
|
fetchImportLists,
|
||||||
|
fetchIndexerFlags,
|
||||||
|
fetchLanguages,
|
||||||
|
fetchQualityProfiles,
|
||||||
|
fetchUISettings,
|
||||||
|
} from 'Store/Actions/settingsActions';
|
||||||
|
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||||
|
import { fetchTags } from 'Store/Actions/tagActions';
|
||||||
|
|
||||||
|
const createErrorsSelector = () =>
|
||||||
|
createSelector(
|
||||||
|
(state: AppState) => state.movies.error,
|
||||||
|
(state: AppState) => state.movieCollections.error,
|
||||||
|
(state: AppState) => state.customFilters.error,
|
||||||
|
(state: AppState) => state.tags.error,
|
||||||
|
(state: AppState) => state.settings.ui.error,
|
||||||
|
(state: AppState) => state.settings.qualityProfiles.error,
|
||||||
|
(state: AppState) => state.settings.languages.error,
|
||||||
|
(state: AppState) => state.settings.importLists.error,
|
||||||
|
(state: AppState) => state.settings.indexerFlags.error,
|
||||||
|
(state: AppState) => state.system.status.error,
|
||||||
|
(state: AppState) => state.app.translations.error,
|
||||||
|
(
|
||||||
|
moviesError,
|
||||||
|
movieCollectionsError,
|
||||||
|
customFiltersError,
|
||||||
|
tagsError,
|
||||||
|
uiSettingsError,
|
||||||
|
qualityProfilesError,
|
||||||
|
languagesError,
|
||||||
|
importListsError,
|
||||||
|
indexerFlagsError,
|
||||||
|
systemStatusError,
|
||||||
|
translationsError
|
||||||
|
) => {
|
||||||
|
const hasError = !!(
|
||||||
|
moviesError ||
|
||||||
|
movieCollectionsError ||
|
||||||
|
customFiltersError ||
|
||||||
|
tagsError ||
|
||||||
|
uiSettingsError ||
|
||||||
|
qualityProfilesError ||
|
||||||
|
languagesError ||
|
||||||
|
importListsError ||
|
||||||
|
indexerFlagsError ||
|
||||||
|
systemStatusError ||
|
||||||
|
translationsError
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasError,
|
||||||
|
errors: {
|
||||||
|
moviesError,
|
||||||
|
movieCollectionsError,
|
||||||
|
customFiltersError,
|
||||||
|
tagsError,
|
||||||
|
uiSettingsError,
|
||||||
|
qualityProfilesError,
|
||||||
|
languagesError,
|
||||||
|
importListsError,
|
||||||
|
indexerFlagsError,
|
||||||
|
systemStatusError,
|
||||||
|
translationsError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const useAppPage = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const isPopulated = useSelector(
|
||||||
|
(state: AppState) =>
|
||||||
|
state.movies.isPopulated &&
|
||||||
|
state.movieCollections.isPopulated &&
|
||||||
|
state.customFilters.isPopulated &&
|
||||||
|
state.tags.isPopulated &&
|
||||||
|
state.settings.ui.isPopulated &&
|
||||||
|
state.settings.qualityProfiles.isPopulated &&
|
||||||
|
state.settings.languages.isPopulated &&
|
||||||
|
state.settings.importLists.isPopulated &&
|
||||||
|
state.settings.indexerFlags.isPopulated &&
|
||||||
|
state.system.status.isPopulated &&
|
||||||
|
state.app.translations.isPopulated
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hasError, errors } = useSelector(createErrorsSelector());
|
||||||
|
|
||||||
|
const isLocalStorageSupported = useMemo(() => {
|
||||||
|
const key = 'radarrTest';
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, key);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchMovies());
|
||||||
|
dispatch(fetchMovieCollections());
|
||||||
|
dispatch(fetchCustomFilters());
|
||||||
|
dispatch(fetchTags());
|
||||||
|
dispatch(fetchQualityProfiles());
|
||||||
|
dispatch(fetchLanguages());
|
||||||
|
dispatch(fetchImportLists());
|
||||||
|
dispatch(fetchIndexerFlags());
|
||||||
|
dispatch(fetchUISettings());
|
||||||
|
dispatch(fetchStatus());
|
||||||
|
dispatch(fetchTranslations());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return { errors, hasError, isLocalStorageSupported, isPopulated };
|
||||||
|
}, [errors, hasError, isLocalStorageSupported, isPopulated]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAppPage;
|
||||||
122
frontend/src/Helpers/Hooks/useKeyboardShortcuts.ts
Normal file
122
frontend/src/Helpers/Hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import Mousetrap, { MousetrapInstance } from 'mousetrap';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
export interface Shortcut {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BindingOptions {
|
||||||
|
isGlobal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shortcuts = {
|
||||||
|
openKeyboardShortcutsModal: {
|
||||||
|
key: '?',
|
||||||
|
get name() {
|
||||||
|
return translate('KeyboardShortcutsOpenModal');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal: {
|
||||||
|
key: 'Esc',
|
||||||
|
get name() {
|
||||||
|
return translate('KeyboardShortcutsCloseModal');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptConfirmModal: {
|
||||||
|
key: 'Enter',
|
||||||
|
get name() {
|
||||||
|
return translate('KeyboardShortcutsConfirmModal');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
focusMovieSearchInput: {
|
||||||
|
key: 's',
|
||||||
|
get name() {
|
||||||
|
return translate('KeyboardShortcutsFocusSearchBox');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSettings: {
|
||||||
|
key: 'mod+s',
|
||||||
|
get name() {
|
||||||
|
return translate('KeyboardShortcutsSaveSettings');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function useKeyboardShortcuts() {
|
||||||
|
const bindings = useRef<Record<string, BindingOptions>>({});
|
||||||
|
const mouseTrap = useRef<MousetrapInstance | null>();
|
||||||
|
|
||||||
|
const handleStop = useCallback(
|
||||||
|
(_e: Mousetrap.ExtendedKeyboardEvent, element: Element, combo: string) => {
|
||||||
|
const binding = bindings.current[combo];
|
||||||
|
|
||||||
|
if (!binding || binding.isGlobal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
element.tagName === 'INPUT' ||
|
||||||
|
element.tagName === 'SELECT' ||
|
||||||
|
element.tagName === 'TEXTAREA' ||
|
||||||
|
('contentEditable' in element && element.contentEditable === 'true')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindShortcut = useCallback(
|
||||||
|
(
|
||||||
|
shortcutKey: keyof typeof shortcuts,
|
||||||
|
callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void,
|
||||||
|
options: BindingOptions = {}
|
||||||
|
) => {
|
||||||
|
const shortcut = shortcuts[shortcutKey];
|
||||||
|
|
||||||
|
mouseTrap.current?.bind(shortcut.key, callback);
|
||||||
|
bindings.current[shortcut.key] = options;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unbindShortcut = useCallback((shortcutKey: keyof typeof shortcuts) => {
|
||||||
|
const shortcut = shortcuts[shortcutKey];
|
||||||
|
|
||||||
|
delete bindings.current[shortcut.key];
|
||||||
|
mouseTrap.current?.unbind(shortcut.key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mouseTrap.current = new Mousetrap();
|
||||||
|
mouseTrap.current.stopCallback = handleStop;
|
||||||
|
|
||||||
|
const localMouseTrap = mouseTrap.current;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const keys = Object.keys(bindings.current);
|
||||||
|
|
||||||
|
if (!keys.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.forEach((binding) => {
|
||||||
|
localMouseTrap.unbind(binding);
|
||||||
|
});
|
||||||
|
|
||||||
|
bindings.current = {};
|
||||||
|
mouseTrap.current = null;
|
||||||
|
};
|
||||||
|
}, [handleStop]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ bindShortcut, unbindShortcut }),
|
||||||
|
[bindShortcut, unbindShortcut]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useKeyboardShortcuts;
|
||||||
|
|
@ -15,7 +15,7 @@ import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import PageContent from 'Components/Page/PageContent';
|
import PageContent from 'Components/Page/PageContent';
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
import PageJumpBar from 'Components/Page/PageJumpBar';
|
import PageJumpBar, { PageJumpBarItems } from 'Components/Page/PageJumpBar';
|
||||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||||
|
|
@ -194,10 +194,11 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
|
||||||
[setJumpToCharacter]
|
[setJumpToCharacter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const jumpBarItems = useMemo(() => {
|
const jumpBarItems: PageJumpBarItems = useMemo(() => {
|
||||||
// Reset if not sorting by sortTitle
|
// Reset if not sorting by sortTitle
|
||||||
if (sortKey !== 'sortTitle') {
|
if (sortKey !== 'sortTitle') {
|
||||||
return {
|
return {
|
||||||
|
characters: {},
|
||||||
order: [],
|
order: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { IconName } from 'Components/Icon';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
|
@ -20,7 +20,7 @@ interface RowProps {
|
||||||
|
|
||||||
interface RowInfoProps {
|
interface RowInfoProps {
|
||||||
title: string;
|
title: string;
|
||||||
iconName: IconDefinition;
|
iconName: IconName;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon, { IconProps } from 'Components/Icon';
|
import Icon, { IconName } from 'Components/Icon';
|
||||||
import styles from './MovieIndexOverviewInfoRow.css';
|
import styles from './MovieIndexOverviewInfoRow.css';
|
||||||
|
|
||||||
interface MovieIndexOverviewInfoRowProps {
|
interface MovieIndexOverviewInfoRowProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
iconName: IconProps['name'];
|
iconName: IconName;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/SelectContext';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton, {
|
||||||
|
PageToolbarButtonProps,
|
||||||
|
} from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
interface MovieIndexSelectAllButtonProps {
|
interface MovieIndexSelectAllButtonProps
|
||||||
label: string;
|
extends Omit<PageToolbarButtonProps, 'iconName'> {
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
overflowComponent: React.FunctionComponent<never>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
|
function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
|
||||||
const { isSelectMode } = props;
|
const { isSelectMode, overflowComponent } = props;
|
||||||
const [selectState, selectDispatch] = useSelect();
|
const [selectState, selectDispatch] = useSelect();
|
||||||
const { allSelected, allUnselected } = selectState;
|
const { allSelected, allUnselected } = selectState;
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
|
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
|
||||||
iconName={icon}
|
iconName={icon}
|
||||||
|
overflowComponent={overflowComponent}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/SelectContext';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton, {
|
||||||
|
PageToolbarButtonProps,
|
||||||
|
} from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
|
|
||||||
interface MovieIndexSelectModeButtonProps {
|
interface MovieIndexSelectModeButtonProps extends PageToolbarButtonProps {
|
||||||
label: string;
|
|
||||||
iconName: IconDefinition;
|
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
overflowComponent: React.FunctionComponent<never>;
|
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
|
function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
|
||||||
const { label, iconName, isSelectMode, onPress } = props;
|
const { label, iconName, isSelectMode, overflowComponent, onPress } = props;
|
||||||
const [, selectDispatch] = useSelect();
|
const [, selectDispatch] = useSelect();
|
||||||
|
|
||||||
const onPressWrapper = useCallback(() => {
|
const onPressWrapper = useCallback(() => {
|
||||||
|
|
@ -29,6 +27,7 @@ function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={label}
|
label={label}
|
||||||
iconName={iconName}
|
iconName={iconName}
|
||||||
|
overflowComponent={overflowComponent}
|
||||||
onPress={onPressWrapper}
|
onPress={onPressWrapper}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/SelectContext';
|
||||||
|
import { IconName } from 'Components/Icon';
|
||||||
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
|
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
|
||||||
|
|
||||||
interface MovieIndexSelectModeMenuItemProps {
|
interface MovieIndexSelectModeMenuItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
iconName: IconDefinition;
|
iconName: IconName;
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
|
import { Tag } from 'App/State/TagsAppState';
|
||||||
|
|
||||||
function createTagsSelector() {
|
function createTagsSelector(): (state: AppState) => Tag[] {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state: AppState) => state.tags.items,
|
(state: AppState) => state.tags.items,
|
||||||
(tags) => {
|
(tags) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Error } from 'App/State/AppSectionState';
|
import { Error } from 'App/State/AppSectionState';
|
||||||
|
|
||||||
function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) {
|
function getErrorMessage(xhr: Error | undefined, fallbackErrorMessage = '') {
|
||||||
if (!xhr || !xhr.responseJSON) {
|
if (!xhr || !xhr.responseJSON) {
|
||||||
return fallbackErrorMessage;
|
return fallbackErrorMessage;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export default interface UiSettings {
|
||||||
shortDateFormat: string;
|
shortDateFormat: string;
|
||||||
longDateFormat: string;
|
longDateFormat: string;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
|
enableColorImpairedMode: boolean;
|
||||||
movieRuntimeFormat: string;
|
movieRuntimeFormat: string;
|
||||||
movieInfoLanguage: number;
|
movieInfoLanguage: number;
|
||||||
uiLanguage: number;
|
uiLanguage: number;
|
||||||
|
|
|
||||||
10
frontend/typings/worker-loader.d.ts
vendored
Normal file
10
frontend/typings/worker-loader.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
declare module '*.worker.ts' {
|
||||||
|
// You need to change `Worker`, if you specified a different value for the `workerType` option
|
||||||
|
class WebpackWorker extends Worker {
|
||||||
|
constructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment this if you set the `esModule` option to `false`
|
||||||
|
// export = WebpackWorker;
|
||||||
|
export default WebpackWorker;
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"element-class": "0.2.2",
|
"element-class": "0.2.2",
|
||||||
"filesize": "10.1.6",
|
"filesize": "10.1.6",
|
||||||
"fuse.js": "6.6.2",
|
"fuse.js": "7.0.0",
|
||||||
"history": "4.10.1",
|
"history": "4.10.1",
|
||||||
"jdu": "1.0.0",
|
"jdu": "1.0.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1",
|
||||||
|
|
@ -96,6 +96,7 @@
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
"@babel/preset-typescript": "7.26.0",
|
"@babel/preset-typescript": "7.26.0",
|
||||||
"@types/lodash": "4.14.195",
|
"@types/lodash": "4.14.195",
|
||||||
|
"@types/mousetrap": "1.6.15",
|
||||||
"@types/react-autosuggest": "10.1.11",
|
"@types/react-autosuggest": "10.1.11",
|
||||||
"@types/react-document-title": "2.0.10",
|
"@types/react-document-title": "2.0.10",
|
||||||
"@types/react-google-recaptcha": "2.1.9",
|
"@types/react-google-recaptcha": "2.1.9",
|
||||||
|
|
|
||||||
13
yarn.lock
13
yarn.lock
|
|
@ -1357,6 +1357,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
|
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
|
||||||
integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==
|
integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==
|
||||||
|
|
||||||
|
"@types/mousetrap@1.6.15":
|
||||||
|
version "1.6.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86"
|
||||||
|
integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*":
|
||||||
version "22.7.5"
|
version "22.7.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b"
|
||||||
|
|
@ -3556,10 +3561,10 @@ functions-have-names@^1.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||||
|
|
||||||
fuse.js@6.6.2:
|
fuse.js@7.0.0:
|
||||||
version "6.6.2"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
|
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
|
||||||
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
|
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
|
||||||
|
|
||||||
gensync@^1.0.0-beta.2:
|
gensync@^1.0.0-beta.2:
|
||||||
version "1.0.0-beta.2"
|
version "1.0.0-beta.2"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue