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 isProduction = !!env.production;
|
||||
const isProfiling = isProduction && !!env.profile;
|
||||
const inlineWebWorkers = 'no-fallback';
|
||||
|
||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||
|
||||
|
|
@ -160,16 +159,6 @@ module.exports = (env) => {
|
|||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.js$/,
|
||||
use: {
|
||||
loader: 'worker-loader',
|
||||
options: {
|
||||
filename: '[name].js',
|
||||
inline: inlineWebWorkers
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: [/\.jsx?$/, /\.tsx?$/],
|
||||
exclude: /(node_modules|JsLibraries)/,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React from 'react';
|
|||
import DocumentTitle from 'react-document-title';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import PageConnector from 'Components/Page/PageConnector';
|
||||
import Page from 'Components/Page/Page';
|
||||
import ApplyTheme from './ApplyTheme';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
|
|
@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
|
|||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<Page>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</Page>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Error } from './AppSectionState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CaptchaAppState from './CaptchaAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import CustomFiltersAppState from './CustomFiltersAppState';
|
||||
import ExtraFilesAppState from './ExtraFilesAppState';
|
||||
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
|
|
@ -55,7 +57,9 @@ export interface CustomFilter {
|
|||
}
|
||||
|
||||
export interface AppSectionState {
|
||||
isUpdated: boolean;
|
||||
isConnected: boolean;
|
||||
isDisconnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
|
|
@ -65,6 +69,10 @@ export interface AppSectionState {
|
|||
width: number;
|
||||
height: number;
|
||||
};
|
||||
translations: {
|
||||
error?: Error;
|
||||
isPopulated: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
|
|
@ -73,6 +81,7 @@ interface AppState {
|
|||
calendar: CalendarAppState;
|
||||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
customFilters: CustomFiltersAppState;
|
||||
extraFiles: ExtraFilesAppState;
|
||||
history: HistoryAppState;
|
||||
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 { shortcuts } from 'Components/keyboardShortcuts';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Shortcut, shortcuts } from 'Components/keyboardShortcuts';
|
||||
import Button from 'Components/Link/Button';
|
||||
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 createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './KeyboardShortcutsModalContent.css';
|
||||
|
||||
function getShortcuts() {
|
||||
const allShortcuts = [];
|
||||
const allShortcuts: Shortcut[] = [];
|
||||
|
||||
Object.keys(shortcuts).forEach((key) => {
|
||||
allShortcuts.push(shortcuts[key]);
|
||||
|
|
@ -19,8 +20,8 @@ function getShortcuts() {
|
|||
return allShortcuts;
|
||||
}
|
||||
|
||||
function getShortcutKey(combo, isOsx) {
|
||||
const comboMatch = combo.match(/(.+?)\+(.*)/);
|
||||
function getShortcutKey(combo: string, isOsx: boolean) {
|
||||
const comboMatch = combo.match(/(.+?)\+(.)/);
|
||||
|
||||
if (!comboMatch) {
|
||||
return combo;
|
||||
|
|
@ -45,55 +46,39 @@ function getShortcutKey(combo, isOsx) {
|
|||
return `${osModifier} + ${key}`;
|
||||
}
|
||||
|
||||
function KeyboardShortcutsModalContent(props) {
|
||||
const {
|
||||
isOsx,
|
||||
onModalClose
|
||||
} = props;
|
||||
interface KeyboardShortcutsModalContentProps {
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function KeyboardShortcutsModalContent({
|
||||
onModalClose,
|
||||
}: KeyboardShortcutsModalContentProps) {
|
||||
const { isOsx } = useSelector(createSystemStatusSelector());
|
||||
const allShortcuts = getShortcuts();
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('KeyboardShortcuts')}
|
||||
</ModalHeader>
|
||||
<ModalHeader>{translate('KeyboardShortcuts')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
allShortcuts.map((shortcut) => {
|
||||
{allShortcuts.map((shortcut) => {
|
||||
return (
|
||||
<div
|
||||
key={shortcut.name}
|
||||
className={styles.shortcut}
|
||||
>
|
||||
<div key={shortcut.name} className={styles.shortcut}>
|
||||
<div className={styles.key}>
|
||||
{getShortcutKey(shortcut.key, isOsx)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{shortcut.name}
|
||||
</div>
|
||||
<div>{shortcut.name}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
})}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
KeyboardShortcutsModalContent.propTypes = {
|
||||
isOsx: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.logoFull,
|
||||
.logo {
|
||||
vertical-align: middle;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface CssExports {
|
|||
'logo': string;
|
||||
'logoContainer': string;
|
||||
'logoFull': string;
|
||||
'logoLink': string;
|
||||
'right': string;
|
||||
'sidebarToggleContainer': 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 { SuggestedMovie } from './MovieSearchInput';
|
||||
|
||||
const fuseOptions = {
|
||||
shouldSort: true,
|
||||
|
|
@ -6,35 +7,27 @@ const fuseOptions = {
|
|||
ignoreLocation: true,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: 1,
|
||||
keys: [
|
||||
'title',
|
||||
'alternateTitles.title',
|
||||
'tmdbId',
|
||||
'imdbId',
|
||||
'tags.label'
|
||||
]
|
||||
keys: ['title', 'alternateTitles.title', 'tmdbId', 'imdbId', 'tags.label'],
|
||||
};
|
||||
|
||||
function getSuggestions(movies, value) {
|
||||
function getSuggestions(movies: SuggestedMovie[], value: string) {
|
||||
const limit = 10;
|
||||
let suggestions = [];
|
||||
|
||||
if (value.length === 1) {
|
||||
for (let i = 0; i < movies.length; i++) {
|
||||
const s = movies[i];
|
||||
if (s.firstCharacter === value.toLowerCase()) {
|
||||
const m = movies[i];
|
||||
if (m.firstCharacter === value.toLowerCase()) {
|
||||
suggestions.push({
|
||||
item: movies[i],
|
||||
indices: [
|
||||
[0, 0]
|
||||
],
|
||||
indices: [[0, 0]],
|
||||
matches: [
|
||||
{
|
||||
value: s.title,
|
||||
key: 'title'
|
||||
}
|
||||
value: m.title,
|
||||
key: 'title',
|
||||
},
|
||||
],
|
||||
refIndex: 0
|
||||
refIndex: 0,
|
||||
});
|
||||
if (suggestions.length > limit) {
|
||||
break;
|
||||
|
|
@ -49,21 +42,18 @@ function getSuggestions(movies, value) {
|
|||
return suggestions;
|
||||
}
|
||||
|
||||
onmessage = function(e) {
|
||||
onmessage = function (e) {
|
||||
if (!e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
movies,
|
||||
value
|
||||
} = e.data;
|
||||
const { movies, value } = e.data;
|
||||
|
||||
const suggestions = getSuggestions(movies, value);
|
||||
|
||||
const results = {
|
||||
value,
|
||||
suggestions
|
||||
suggestions,
|
||||
};
|
||||
|
||||
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 ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
|
||||
import ErrorBoundaryError, {
|
||||
ErrorBoundaryErrorProps,
|
||||
} from 'Components/Error/ErrorBoundaryError';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import PageContentBody from './PageContentBody';
|
||||
import styles from './PageContentError.css';
|
||||
|
||||
function PageContentError(props) {
|
||||
function PageContentError(props: ErrorBoundaryErrorProps) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<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 from 'react';
|
||||
import React, { SyntheticEvent } from 'react';
|
||||
import { IconName } from 'Components/Icon';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import SpinnerIcon from 'Components/SpinnerIcon';
|
||||
import styles from './PageToolbarOverflowMenuItem.css';
|
||||
|
||||
interface PageToolbarOverflowMenuItemProps {
|
||||
iconName: IconDefinition;
|
||||
spinningName?: IconDefinition;
|
||||
iconName: IconName;
|
||||
spinningName?: IconName;
|
||||
isDisabled?: boolean;
|
||||
isSpinning?: boolean;
|
||||
showIndicator?: boolean;
|
||||
label: string;
|
||||
text?: string;
|
||||
onPress: () => void;
|
||||
onPress?: (event: SyntheticEvent<Element, Event>) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
scrollTop?: number;
|
||||
scrollDirection: ScrollDirection;
|
||||
autoHide: boolean;
|
||||
autoScroll: boolean;
|
||||
autoHide?: boolean;
|
||||
autoScroll?: boolean;
|
||||
children?: React.ReactNode;
|
||||
onScroll?: (payload: OnScroll) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon, { IconProps } from './Icon';
|
||||
import Icon, { IconName, IconProps } from './Icon';
|
||||
|
||||
export interface SpinnerIconProps extends IconProps {
|
||||
spinningName?: IconProps['name'];
|
||||
spinningName?: IconName;
|
||||
isSpinning: Required<IconProps['isSpinning']>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,81 +1,99 @@
|
|||
import Mousetrap from 'mousetrap';
|
||||
import React, { Component } from 'react';
|
||||
import getDisplayName from 'Helpers/getDisplayName';
|
||||
import Mousetrap, { MousetrapInstance } from 'mousetrap';
|
||||
import React, { Component, ComponentType } from 'react';
|
||||
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: {
|
||||
key: '?',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsOpenModal');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
CLOSE_MODAL: {
|
||||
key: 'Esc',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsCloseModal');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
ACCEPT_CONFIRM_MODAL: {
|
||||
key: 'Enter',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsConfirmModal');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
MOVIE_SEARCH_INPUT: {
|
||||
key: 's',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsFocusSearchBox');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
SAVE_SETTINGS: {
|
||||
key: 'mod+s',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsSaveSettings');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
SCROLL_TOP: {
|
||||
key: 'mod+home',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsMovieIndexScrollTop');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
SCROLL_BOTTOM: {
|
||||
key: 'mod+end',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsMovieIndexScrollBottom');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
DETAILS_NEXT: {
|
||||
key: '→',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsMovieDetailsNextMovie');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
DETAILS_PREVIOUS: {
|
||||
key: '←',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function keyboardShortcuts(WrappedComponent) {
|
||||
function keyboardShortcuts(
|
||||
WrappedComponent: ComponentType<KeyboardShortcutsProps>
|
||||
) {
|
||||
class KeyboardShortcuts extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
constructor(props: never) {
|
||||
super(props);
|
||||
this._mousetrapBindings = {};
|
||||
this._mousetrap = new Mousetrap();
|
||||
this._mousetrap.stopCallback = this.stopCallback;
|
||||
|
|
@ -86,15 +104,22 @@ function keyboardShortcuts(WrappedComponent) {
|
|||
this._mousetrap = null;
|
||||
}
|
||||
|
||||
_mousetrap: MousetrapInstance | null;
|
||||
_mousetrapBindings: Record<string, BindingOptions>;
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
bindShortcut = (key, callback, options = {}) => {
|
||||
this._mousetrap.bind(key, callback);
|
||||
bindShortcut = (
|
||||
key: string,
|
||||
callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void,
|
||||
options: BindingOptions = {}
|
||||
) => {
|
||||
this._mousetrap?.bind(key, callback);
|
||||
this._mousetrapBindings[key] = options;
|
||||
};
|
||||
|
||||
unbindShortcut = (key) => {
|
||||
unbindShortcut = (key: string) => {
|
||||
if (this._mousetrap != null) {
|
||||
delete this._mousetrapBindings[key];
|
||||
this._mousetrap.unbind(key);
|
||||
|
|
@ -109,13 +134,17 @@ function keyboardShortcuts(WrappedComponent) {
|
|||
}
|
||||
|
||||
keys.forEach((binding) => {
|
||||
this._mousetrap.unbind(binding);
|
||||
this._mousetrap?.unbind(binding);
|
||||
});
|
||||
|
||||
this._mousetrapBindings = {};
|
||||
};
|
||||
|
||||
stopCallback = (event, element, combo) => {
|
||||
stopCallback = (
|
||||
_e: Mousetrap.ExtendedKeyboardEvent,
|
||||
element: Element,
|
||||
combo: string
|
||||
) => {
|
||||
const binding = this._mousetrapBindings[combo];
|
||||
|
||||
if (!binding || binding.isGlobal) {
|
||||
|
|
@ -126,7 +155,7 @@ function keyboardShortcuts(WrappedComponent) {
|
|||
element.tagName === 'INPUT' ||
|
||||
element.tagName === 'SELECT' ||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Icon, { IconName } from 'Components/Icon';
|
||||
import styles from './DiscoverMovieOverviewInfoRow.css';
|
||||
|
||||
interface DiscoverMovieOverviewInfoRowProps {
|
||||
title?: string;
|
||||
iconName: IconProps['name'];
|
||||
iconName: IconName;
|
||||
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 PageContent from 'Components/Page/PageContent';
|
||||
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 PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
|
|
@ -194,10 +194,11 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
|
|||
[setJumpToCharacter]
|
||||
);
|
||||
|
||||
const jumpBarItems = useMemo(() => {
|
||||
const jumpBarItems: PageJumpBarItems = useMemo(() => {
|
||||
// Reset if not sorting by sortTitle
|
||||
if (sortKey !== 'sortTitle') {
|
||||
return {
|
||||
characters: {},
|
||||
order: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconName } from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
|
|
@ -20,7 +20,7 @@ interface RowProps {
|
|||
|
||||
interface RowInfoProps {
|
||||
title: string;
|
||||
iconName: IconDefinition;
|
||||
iconName: IconName;
|
||||
label: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Icon, { IconName } from 'Components/Icon';
|
||||
import styles from './MovieIndexOverviewInfoRow.css';
|
||||
|
||||
interface MovieIndexOverviewInfoRowProps {
|
||||
title?: string;
|
||||
iconName: IconProps['name'];
|
||||
iconName: IconName;
|
||||
label: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import React, { useCallback } from 'react';
|
||||
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 translate from 'Utilities/String/translate';
|
||||
|
||||
interface MovieIndexSelectAllButtonProps {
|
||||
label: string;
|
||||
interface MovieIndexSelectAllButtonProps
|
||||
extends Omit<PageToolbarButtonProps, 'iconName'> {
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
}
|
||||
|
||||
function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
|
||||
const { isSelectMode } = props;
|
||||
const { isSelectMode, overflowComponent } = props;
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const { allSelected, allUnselected } = selectState;
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
|
|||
<PageToolbarButton
|
||||
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
|
||||
iconName={icon}
|
||||
overflowComponent={overflowComponent}
|
||||
onPress={onPress}
|
||||
/>
|
||||
) : null;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarButton, {
|
||||
PageToolbarButtonProps,
|
||||
} from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
|
||||
interface MovieIndexSelectModeButtonProps {
|
||||
label: string;
|
||||
iconName: IconDefinition;
|
||||
interface MovieIndexSelectModeButtonProps extends PageToolbarButtonProps {
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
|
||||
const { label, iconName, isSelectMode, onPress } = props;
|
||||
const { label, iconName, isSelectMode, overflowComponent, onPress } = props;
|
||||
const [, selectDispatch] = useSelect();
|
||||
|
||||
const onPressWrapper = useCallback(() => {
|
||||
|
|
@ -29,6 +27,7 @@ function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
|
|||
<PageToolbarButton
|
||||
label={label}
|
||||
iconName={iconName}
|
||||
overflowComponent={overflowComponent}
|
||||
onPress={onPressWrapper}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import { IconName } from 'Components/Icon';
|
||||
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
|
||||
|
||||
interface MovieIndexSelectModeMenuItemProps {
|
||||
label: string;
|
||||
iconName: IconDefinition;
|
||||
iconName: IconName;
|
||||
isSelectMode: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
|
||||
function createTagsSelector() {
|
||||
function createTagsSelector(): (state: AppState) => Tag[] {
|
||||
return createSelector(
|
||||
(state: AppState) => state.tags.items,
|
||||
(tags) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Error } from 'App/State/AppSectionState';
|
||||
|
||||
function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) {
|
||||
function getErrorMessage(xhr: Error | undefined, fallbackErrorMessage = '') {
|
||||
if (!xhr || !xhr.responseJSON) {
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export default interface UiSettings {
|
|||
shortDateFormat: string;
|
||||
longDateFormat: string;
|
||||
timeFormat: string;
|
||||
enableColorImpairedMode: boolean;
|
||||
movieRuntimeFormat: string;
|
||||
movieInfoLanguage: 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",
|
||||
"element-class": "0.2.2",
|
||||
"filesize": "10.1.6",
|
||||
"fuse.js": "6.6.2",
|
||||
"fuse.js": "7.0.0",
|
||||
"history": "4.10.1",
|
||||
"jdu": "1.0.0",
|
||||
"jquery": "3.7.1",
|
||||
|
|
@ -96,6 +96,7 @@
|
|||
"@babel/preset-react": "7.26.3",
|
||||
"@babel/preset-typescript": "7.26.0",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/mousetrap": "1.6.15",
|
||||
"@types/react-autosuggest": "10.1.11",
|
||||
"@types/react-document-title": "2.0.10",
|
||||
"@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"
|
||||
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@*":
|
||||
version "22.7.5"
|
||||
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"
|
||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||
|
||||
fuse.js@6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
|
||||
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
|
||||
fuse.js@7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
|
||||
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
|
||||
|
||||
gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
|
|
|
|||
Loading…
Reference in a new issue