mirror of
https://github.com/Sonarr/Sonarr.git
synced 2025-01-31 20:41:37 +01:00
Convert Page components to TypeScript
This commit is contained in:
parent
e37684e045
commit
d9e5842f8b
@ -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 EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||
import EpisodesAppState from './EpisodesAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
@ -52,7 +54,9 @@ export interface CustomFilter {
|
||||
}
|
||||
|
||||
export interface AppSectionState {
|
||||
isUpdated: boolean;
|
||||
isConnected: boolean;
|
||||
isDisconnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
@ -63,6 +67,10 @@ export interface AppSectionState {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
translations: {
|
||||
error?: Error;
|
||||
isPopulated: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@ -71,6 +79,7 @@ interface AppState {
|
||||
calendar: CalendarAppState;
|
||||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
customFilters: CustomFiltersAppState;
|
||||
episodeFiles: EpisodeFilesAppState;
|
||||
episodeHistory: HistoryAppState;
|
||||
episodes: EpisodesAppState;
|
||||
|
@ -3,7 +3,7 @@ import StackTrace from 'stacktrace-js';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ErrorBoundaryError.css';
|
||||
|
||||
interface ErrorBoundaryErrorProps {
|
||||
export interface ErrorBoundaryErrorProps {
|
||||
className: string;
|
||||
messageClassName: string;
|
||||
detailsClassName: string;
|
||||
|
@ -8,13 +8,15 @@ import { kinds } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import styles from './Icon.css';
|
||||
|
||||
export type IconName = FontAwesomeIconProps['icon'];
|
||||
|
||||
export interface IconProps
|
||||
extends Omit<
|
||||
FontAwesomeIconProps,
|
||||
'icon' | 'spin' | 'name' | 'title' | 'size'
|
||||
> {
|
||||
containerClassName?: ComponentProps<'span'>['className'];
|
||||
name: FontAwesomeIconProps['icon'];
|
||||
name: IconName;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: number;
|
||||
isSpinning?: FontAwesomeIconProps['spin'];
|
||||
|
@ -52,6 +52,7 @@ function ToolbarMenuButton(props) {
|
||||
}
|
||||
|
||||
ToolbarMenuButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
iconName: PropTypes.object.isRequired,
|
||||
showIndicator: PropTypes.bool.isRequired,
|
||||
text: PropTypes.string
|
||||
|
@ -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,65 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ErrorPage.css';
|
||||
|
||||
function ErrorPage(props) {
|
||||
const {
|
||||
version,
|
||||
isLocalStorageSupported,
|
||||
translationsError,
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
qualityProfilesError,
|
||||
uiSettingsError,
|
||||
systemStatusError
|
||||
} = props;
|
||||
|
||||
let errorMessage = translate('FailedToLoadSonarr');
|
||||
|
||||
if (!isLocalStorageSupported) {
|
||||
errorMessage = translate('LocalStorageIsNotSupported');
|
||||
} else if (translationsError) {
|
||||
errorMessage = getErrorMessage(translationsError, translate('FailedToLoadTranslationsFromApi'));
|
||||
} else if (seriesError) {
|
||||
errorMessage = getErrorMessage(seriesError, translate('FailedToLoadSeriesFromApi'));
|
||||
} else if (customFiltersError) {
|
||||
errorMessage = getErrorMessage(customFiltersError, translate('FailedToLoadCustomFiltersFromApi'));
|
||||
} else if (tagsError) {
|
||||
errorMessage = getErrorMessage(tagsError, translate('FailedToLoadTagsFromApi'));
|
||||
} else if (qualityProfilesError) {
|
||||
errorMessage = getErrorMessage(qualityProfilesError, translate('FailedToLoadQualityProfilesFromApi'));
|
||||
} else if (uiSettingsError) {
|
||||
errorMessage = getErrorMessage(uiSettingsError, translate('FailedToLoadUiSettingsFromApi'));
|
||||
} else if (systemStatusError) {
|
||||
errorMessage = getErrorMessage(uiSettingsError, translate('FailedToLoadSystemStatusFromApi'));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.errorMessage}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
|
||||
<div className={styles.version}>
|
||||
{translate('VersionNumber', { version })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ErrorPage.propTypes = {
|
||||
version: PropTypes.string.isRequired,
|
||||
isLocalStorageSupported: PropTypes.bool.isRequired,
|
||||
translationsError: PropTypes.object,
|
||||
seriesError: PropTypes.object,
|
||||
customFiltersError: PropTypes.object,
|
||||
tagsError: PropTypes.object,
|
||||
qualityProfilesError: PropTypes.object,
|
||||
uiSettingsError: PropTypes.object,
|
||||
systemStatusError: PropTypes.object
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
84
frontend/src/Components/Page/ErrorPage.tsx
Normal file
84
frontend/src/Components/Page/ErrorPage.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ErrorPage.css';
|
||||
|
||||
interface ErrorPageProps {
|
||||
version: string;
|
||||
isLocalStorageSupported: boolean;
|
||||
translationsError?: Error;
|
||||
seriesError?: Error;
|
||||
customFiltersError?: Error;
|
||||
tagsError?: Error;
|
||||
qualityProfilesError?: Error;
|
||||
uiSettingsError?: Error;
|
||||
systemStatusError?: Error;
|
||||
}
|
||||
|
||||
function ErrorPage(props: ErrorPageProps) {
|
||||
const {
|
||||
version,
|
||||
isLocalStorageSupported,
|
||||
translationsError,
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
qualityProfilesError,
|
||||
uiSettingsError,
|
||||
systemStatusError,
|
||||
} = props;
|
||||
|
||||
let errorMessage = translate('FailedToLoadSonarr');
|
||||
|
||||
if (!isLocalStorageSupported) {
|
||||
errorMessage = translate('LocalStorageIsNotSupported');
|
||||
} else if (translationsError) {
|
||||
errorMessage = getErrorMessage(
|
||||
translationsError,
|
||||
translate('FailedToLoadTranslationsFromApi')
|
||||
);
|
||||
} else if (seriesError) {
|
||||
errorMessage = getErrorMessage(
|
||||
seriesError,
|
||||
translate('FailedToLoadSeriesFromApi')
|
||||
);
|
||||
} else if (customFiltersError) {
|
||||
errorMessage = getErrorMessage(
|
||||
customFiltersError,
|
||||
translate('FailedToLoadCustomFiltersFromApi')
|
||||
);
|
||||
} else if (tagsError) {
|
||||
errorMessage = getErrorMessage(
|
||||
tagsError,
|
||||
translate('FailedToLoadTagsFromApi')
|
||||
);
|
||||
} else if (qualityProfilesError) {
|
||||
errorMessage = getErrorMessage(
|
||||
qualityProfilesError,
|
||||
translate('FailedToLoadQualityProfilesFromApi')
|
||||
);
|
||||
} else if (uiSettingsError) {
|
||||
errorMessage = getErrorMessage(
|
||||
uiSettingsError,
|
||||
translate('FailedToLoadUiSettingsFromApi')
|
||||
);
|
||||
} else if (systemStatusError) {
|
||||
errorMessage = getErrorMessage(
|
||||
systemStatusError,
|
||||
translate('FailedToLoadSystemStatusFromApi')
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div>{errorMessage}</div>
|
||||
|
||||
<div className={styles.version}>
|
||||
{translate('VersionNumber', { 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,7 +20,7 @@ function getShortcuts() {
|
||||
return allShortcuts;
|
||||
}
|
||||
|
||||
function getShortcutKey(combo, isOsx) {
|
||||
function getShortcutKey(combo: string, isOsx: boolean) {
|
||||
const comboMatch = combo.match(/(.+?)\+(.)/);
|
||||
|
||||
if (!comboMatch) {
|
||||
@ -37,55 +38,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,106 +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 PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
|
||||
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
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.logoContainer}>
|
||||
<Link
|
||||
className={styles.logoLink}
|
||||
to={'/'}
|
||||
>
|
||||
<img
|
||||
className={styles.logo}
|
||||
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
|
||||
alt="Sonarr Logo"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarToggleContainer}>
|
||||
<IconButton
|
||||
id="sidebar-toggle-button"
|
||||
name={icons.NAVBAR_COLLAPSE}
|
||||
onPress={onSidebarToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SeriesSearchInputConnector />
|
||||
|
||||
<div className={styles.right}>
|
||||
<IconButton
|
||||
className={styles.donate}
|
||||
name={icons.HEART}
|
||||
aria-label={translate('Donate')}
|
||||
to="https://sonarr.tv/donate.html"
|
||||
size={14}
|
||||
title={translate('Donate')}
|
||||
/>
|
||||
|
||||
<PageHeaderActionsMenu
|
||||
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={this.state.isKeyboardShortcutsModalOpen}
|
||||
onModalClose={this.onKeyboardShortcutsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageHeader.propTypes = {
|
||||
onSidebarToggle: PropTypes.func.isRequired,
|
||||
bindShortcut: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default keyboardShortcuts(PageHeader);
|
93
frontend/src/Components/Page/Header/PageHeader.tsx
Normal file
93
frontend/src/Components/Page/Header/PageHeader.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
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 PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||
import SeriesSearchInput from './SeriesSearchInput';
|
||||
import styles from './PageHeader.css';
|
||||
|
||||
function PageHeader() {
|
||||
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={styles.logo}
|
||||
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
|
||||
alt="Sonarr Logo"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarToggleContainer}>
|
||||
<IconButton
|
||||
id="sidebar-toggle-button"
|
||||
name={icons.NAVBAR_COLLAPSE}
|
||||
onPress={handleSidebarToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SeriesSearchInput />
|
||||
|
||||
<div className={styles.right}>
|
||||
<IconButton
|
||||
className={styles.donate}
|
||||
name={icons.HEART}
|
||||
aria-label={translate('Donate')}
|
||||
to="https://sonarr.tv/donate.html"
|
||||
size={14}
|
||||
title={translate('Donate')}
|
||||
/>
|
||||
|
||||
<PageHeaderActionsMenu
|
||||
onKeyboardShortcutsPress={handleOpenKeyboardShortcutsModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={isKeyboardShortcutsModalOpen}
|
||||
onModalClose={handleKeyboardShortcutsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeader;
|
@ -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 SeriesSearchResult from './SeriesSearchResult';
|
||||
import styles from './SeriesSearchInput.css';
|
||||
|
||||
const ADD_NEW_TYPE = 'addNew';
|
||||
|
||||
class SeriesSearchInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._autosuggest = null;
|
||||
this._worker = null;
|
||||
|
||||
this.state = {
|
||||
value: '',
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.bindShortcut(shortcuts.SERIES_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.addNewSeriesSuggestion}>
|
||||
{translate('SearchForQuery', { query })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SeriesSearchResult
|
||||
{...item.item}
|
||||
match={item.matches[0]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
goToSeries(item) {
|
||||
this.setState({ value: '' });
|
||||
this.props.onGoToSeries(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.onGoToAddNewSeries(value);
|
||||
this._autosuggest.input.blur();
|
||||
this.reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If an suggestion is not selected go to the first series,
|
||||
// otherwise go to the selected series.
|
||||
|
||||
if (highlightedSuggestionIndex == null) {
|
||||
this.goToSeries(suggestions[0]);
|
||||
} else {
|
||||
this.goToSeries(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,
|
||||
series: this.props.series
|
||||
};
|
||||
|
||||
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,
|
||||
series: this.props.series
|
||||
};
|
||||
|
||||
this.getWorker().postMessage(payload);
|
||||
}
|
||||
};
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
this.setState({
|
||||
suggestions: [],
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
onSuggestionSelected = (event, { suggestion }) => {
|
||||
if (suggestion.type === ADD_NEW_TYPE) {
|
||||
this.props.onGoToAddNewSeries(this.state.value);
|
||||
} else {
|
||||
this.goToSeries(suggestion);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
loading,
|
||||
suggestions
|
||||
} = this.state;
|
||||
|
||||
const suggestionGroups = [];
|
||||
|
||||
if (suggestions.length || loading) {
|
||||
suggestionGroups.push({
|
||||
title: translate('ExistingSeries'),
|
||||
loading,
|
||||
suggestions
|
||||
});
|
||||
}
|
||||
|
||||
suggestionGroups.push({
|
||||
title: translate('AddNewSeries'),
|
||||
suggestions: [
|
||||
{
|
||||
type: ADD_NEW_TYPE,
|
||||
title: value
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const inputProps = {
|
||||
ref: this.setInputRef,
|
||||
className: styles.input,
|
||||
name: 'seriesSearch',
|
||||
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.seriesContainer,
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesSearchInput.propTypes = {
|
||||
series: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onGoToSeries: PropTypes.func.isRequired,
|
||||
onGoToAddNewSeries: PropTypes.func.isRequired,
|
||||
bindShortcut: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default keyboardShortcuts(SeriesSearchInput);
|
460
frontend/src/Components/Page/Header/SeriesSearchInput.tsx
Normal file
460
frontend/src/Components/Page/Header/SeriesSearchInput.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
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 Series from 'Series/Series';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SeriesSearchResult from './SeriesSearchResult';
|
||||
import styles from './SeriesSearchInput.css';
|
||||
|
||||
const ADD_NEW_TYPE = 'addNew';
|
||||
|
||||
interface Match {
|
||||
key: string;
|
||||
refIndex: number;
|
||||
}
|
||||
|
||||
interface AddNewSeriesSuggestion {
|
||||
type: 'addNew';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SuggestedSeries
|
||||
extends Pick<
|
||||
Series,
|
||||
| 'title'
|
||||
| 'titleSlug'
|
||||
| 'sortTitle'
|
||||
| 'images'
|
||||
| 'alternateTitles'
|
||||
| 'tvdbId'
|
||||
| 'tvMazeId'
|
||||
| 'imdbId'
|
||||
| 'tmdbId'
|
||||
> {
|
||||
firstCharacter: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
interface SeriesSuggestion {
|
||||
title: string;
|
||||
indices: number[];
|
||||
item: SuggestedSeries;
|
||||
matches: Match[];
|
||||
refIndex: number;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
|
||||
}
|
||||
|
||||
function createUnoptimizedSelector() {
|
||||
return createSelector(
|
||||
createAllSeriesSelector(),
|
||||
createTagsSelector(),
|
||||
(allSeries, allTags) => {
|
||||
return allSeries.map((series): SuggestedSeries => {
|
||||
const {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles = [],
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tags = [],
|
||||
} = series;
|
||||
|
||||
return {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
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 createSeriesSelector() {
|
||||
return createDeepEqualSelector(
|
||||
createUnoptimizedSelector(),
|
||||
(series) => series
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesSearchInput() {
|
||||
const series = useSelector(createSeriesSelector());
|
||||
const dispatch = useDispatch();
|
||||
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [requestLoading, setRequestLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SeriesSuggestion[]>([]);
|
||||
|
||||
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('ExistingSeries'),
|
||||
loading: isLoading.current,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
title: translate('AddNewSeries'),
|
||||
suggestions: [
|
||||
{
|
||||
type: ADD_NEW_TYPE,
|
||||
title: value,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [suggestions, value]);
|
||||
|
||||
const handleSuggestionsReceived = useCallback(
|
||||
(message: { data: { value: string; suggestions: SeriesSuggestion[] } }) => {
|
||||
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,
|
||||
series,
|
||||
};
|
||||
|
||||
worker.current?.postMessage(payload);
|
||||
}
|
||||
},
|
||||
[series]
|
||||
);
|
||||
|
||||
const requestSuggestions = useDebouncedCallback((value: string) => {
|
||||
if (!isLoading.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestValue.current = value;
|
||||
setRequestLoading(true);
|
||||
|
||||
if (!requestLoading) {
|
||||
const payload = {
|
||||
value,
|
||||
series,
|
||||
};
|
||||
|
||||
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: AddNewSeriesSuggestion | SeriesSuggestion,
|
||||
{ query }: { query: string }
|
||||
) => {
|
||||
if ('type' in item) {
|
||||
return (
|
||||
<div className={styles.addNewSeriesSuggestion}>
|
||||
{translate('SearchForQuery', { query })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SeriesSearchResult {...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.Sonarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
|
||||
)
|
||||
);
|
||||
|
||||
inputRef.current?.blur();
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If an suggestion is not selected go to the first series,
|
||||
// otherwise go to the selected series.
|
||||
|
||||
const selectedSuggestion =
|
||||
highlightedSuggestionIndex == null
|
||||
? suggestions[0]
|
||||
: suggestions[highlightedSuggestionIndex];
|
||||
|
||||
dispatch(
|
||||
push(
|
||||
`${window.Sonarr.urlBase}/series/${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: SeriesSuggestion | AddNewSeriesSuggestion }
|
||||
) => {
|
||||
if ('type' in suggestion) {
|
||||
dispatch(
|
||||
push(
|
||||
`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setValue('');
|
||||
dispatch(
|
||||
push(`${window.Sonarr.urlBase}/series/${suggestion.item.titleSlug}`)
|
||||
);
|
||||
}
|
||||
},
|
||||
[value, dispatch]
|
||||
);
|
||||
|
||||
const inputProps = {
|
||||
ref: inputRef,
|
||||
className: styles.input,
|
||||
name: 'seriesSearch',
|
||||
value,
|
||||
placeholder: translate('Search'),
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
onBlur: handleBlur,
|
||||
};
|
||||
|
||||
const theme = {
|
||||
container: styles.container,
|
||||
containerOpen: styles.containerOpen,
|
||||
suggestionsContainer: styles.seriesContainer,
|
||||
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('focusSeriesSearchInput', focusInput);
|
||||
|
||||
return () => {
|
||||
unbindShortcut('focusSeriesSearchInput');
|
||||
};
|
||||
}, [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 SeriesSearchInput;
|
@ -1,77 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import SeriesSearchInput from './SeriesSearchInput';
|
||||
|
||||
function createCleanSeriesSelector() {
|
||||
return createSelector(
|
||||
createAllSeriesSelector(),
|
||||
createTagsSelector(),
|
||||
(allSeries, allTags) => {
|
||||
return allSeries.map((series) => {
|
||||
const {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles = [],
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tags = []
|
||||
} = series;
|
||||
|
||||
return {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
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(
|
||||
createCleanSeriesSelector(),
|
||||
(series) => {
|
||||
return {
|
||||
series
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onGoToSeries(titleSlug) {
|
||||
dispatch(push(`${window.Sonarr.urlBase}/series/${titleSlug}`));
|
||||
},
|
||||
|
||||
onGoToAddNewSeries(query) {
|
||||
dispatch(push(`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesSearchInput);
|
@ -1,114 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import styles from './SeriesSearchResult.css';
|
||||
|
||||
function SeriesSearchResult(props) {
|
||||
const {
|
||||
match,
|
||||
title,
|
||||
images,
|
||||
alternateTitles,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
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}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
lazy={false}
|
||||
overflow={true}
|
||||
/>
|
||||
|
||||
<div className={styles.titles}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{
|
||||
alternateTitle ?
|
||||
<div className={styles.alternateTitle}>
|
||||
{alternateTitle.title}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
match.key === 'tvdbId' && tvdbId ?
|
||||
<div className={styles.alternateTitle}>
|
||||
TvdbId: {tvdbId}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
match.key === 'tvMazeId' && tvMazeId ?
|
||||
<div className={styles.alternateTitle}>
|
||||
TvMazeId: {tvMazeId}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
match.key === 'imdbId' && imdbId ?
|
||||
<div className={styles.alternateTitle}>
|
||||
ImdbId: {imdbId}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
match.key === 'tmdbId' && tmdbId ?
|
||||
<div className={styles.alternateTitle}>
|
||||
TmdbId: {tmdbId}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
tag ?
|
||||
<div className={styles.tagContainer}>
|
||||
<Label
|
||||
key={tag.id}
|
||||
kind={kinds.INFO}
|
||||
>
|
||||
{tag.label}
|
||||
</Label>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesSearchResult.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
tvdbId: PropTypes.number,
|
||||
tvMazeId: PropTypes.number,
|
||||
imdbId: PropTypes.string,
|
||||
tmdbId: PropTypes.number,
|
||||
tags: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
match: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default SeriesSearchResult;
|
85
frontend/src/Components/Page/Header/SeriesSearchResult.tsx
Normal file
85
frontend/src/Components/Page/Header/SeriesSearchResult.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { SuggestedSeries } from './SeriesSearchInput';
|
||||
import styles from './SeriesSearchResult.css';
|
||||
|
||||
interface Match {
|
||||
key: string;
|
||||
refIndex: number;
|
||||
}
|
||||
|
||||
interface SeriesSearchResultProps extends SuggestedSeries {
|
||||
match: Match;
|
||||
}
|
||||
|
||||
function SeriesSearchResult(props: SeriesSearchResultProps) {
|
||||
const {
|
||||
match,
|
||||
title,
|
||||
images,
|
||||
alternateTitles,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
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}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
lazy={false}
|
||||
overflow={true}
|
||||
/>
|
||||
|
||||
<div className={styles.titles}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
{alternateTitle ? (
|
||||
<div className={styles.alternateTitle}>{alternateTitle.title}</div>
|
||||
) : null}
|
||||
|
||||
{match.key === 'tvdbId' && tvdbId ? (
|
||||
<div className={styles.alternateTitle}>TvdbId: {tvdbId}</div>
|
||||
) : null}
|
||||
|
||||
{match.key === 'tvMazeId' && tvMazeId ? (
|
||||
<div className={styles.alternateTitle}>TvMazeId: {tvMazeId}</div>
|
||||
) : null}
|
||||
|
||||
{match.key === 'imdbId' && imdbId ? (
|
||||
<div className={styles.alternateTitle}>ImdbId: {imdbId}</div>
|
||||
) : null}
|
||||
|
||||
{match.key === 'tmdbId' && tmdbId ? (
|
||||
<div className={styles.alternateTitle}>TmdbId: {tmdbId}</div>
|
||||
) : null}
|
||||
|
||||
{tag ? (
|
||||
<div className={styles.tagContainer}>
|
||||
<Label key={tag.id} kind={kinds.INFO}>
|
||||
{tag.label}
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesSearchResult;
|
@ -1,4 +1,7 @@
|
||||
// eslint-disable filenames/match-exported
|
||||
|
||||
import Fuse from 'fuse.js';
|
||||
import { SuggestedSeries } from './SeriesSearchInput';
|
||||
|
||||
const fuseOptions = {
|
||||
shouldSort: true,
|
||||
@ -14,11 +17,11 @@ const fuseOptions = {
|
||||
'tvMazeId',
|
||||
'imdbId',
|
||||
'tmdbId',
|
||||
'tags.label'
|
||||
]
|
||||
'tags.label',
|
||||
],
|
||||
};
|
||||
|
||||
function getSuggestions(series, value) {
|
||||
function getSuggestions(series: SuggestedSeries[], value: string) {
|
||||
const limit = 10;
|
||||
let suggestions = [];
|
||||
|
||||
@ -28,16 +31,14 @@ function getSuggestions(series, value) {
|
||||
if (s.firstCharacter === value.toLowerCase()) {
|
||||
suggestions.push({
|
||||
item: series[i],
|
||||
indices: [
|
||||
[0, 0]
|
||||
],
|
||||
indices: [[0, 0]],
|
||||
matches: [
|
||||
{
|
||||
value: s.title,
|
||||
key: 'title'
|
||||
}
|
||||
key: 'title',
|
||||
},
|
||||
],
|
||||
refIndex: 0
|
||||
refIndex: 0,
|
||||
});
|
||||
if (suggestions.length > limit) {
|
||||
break;
|
||||
@ -57,16 +58,13 @@ onmessage = function(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
series,
|
||||
value
|
||||
} = e.data;
|
||||
const { series, value } = e.data;
|
||||
|
||||
const suggestions = getSuggestions(series, value);
|
||||
|
||||
const results = {
|
||||
value,
|
||||
suggestions
|
||||
suggestions,
|
||||
};
|
||||
|
||||
self.postMessage(results);
|
@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import LoadingMessage from 'Components/Loading/LoadingMessage';
|
||||
import styles from './LoadingPage.css';
|
||||
|
||||
const sonarrLogo = 'data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjIxNi45IiB2aWV3Qm94PSIwIDAgMjE2LjcgMjE2LjkiIHdpZHRoPSIyMTYuNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMTYuNyAxMDguNDVjMCAyOS44MzMtMTAuNTMzIDU1LjQtMzEuNiA3Ni43LS43LjgzMy0xLjQ4MyAxLjYtMi4zNSAyLjMtMy40NjYgMy40LTcuMTMzIDYuNDg0LTExIDkuMjUtMTguMjY3IDEzLjQ2Ny0zOS4zNjcgMjAuMi02My4zIDIwLjItMjMuOTY3IDAtNDUuMDMzLTYuNzMzLTYzLjItMjAuMi00LjgtMy40LTkuMy03LjI1LTEzLjUtMTEuNTUtMTYuMzY3LTE2LjI2Ni0yNi40MTctMzUuMTY3LTMwLjE1LTU2LjctLjczMy00LjItMS4yMTctOC40NjctMS40NS0xMi44LS4xLTIuNC0uMTUtNC44LS4xNS03LjIgMC0yLjUzMy4wNS00Ljk1LjE1LTcuMjUgMC0uMjMzLjA2Ni0uNDY3LjItLjcgMS41NjctMjYuNiAxMi4wMzMtNDkuNTgzIDMxLjQtNjguOTVDNTMuMDUgMTAuNTE3IDc4LjYxNyAwIDEwOC40NSAwYzI5LjkzMyAwIDU1LjQ4NCAxMC41MTcgNzYuNjUgMzEuNTUgMjEuMDY3IDIxLjQzMyAzMS42IDQ3LjA2NyAzMS42IDc2Ljl6IiBmaWxsPSIjRUVFIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOTQuNjUgNDIuNWwtMjIuNCAyMi40QzE1OS4xNTIgNzcuOTk4IDE1OCA4OS40IDE1OCAxMDkuNWMwIDE3LjkzNCAyLjg1MiAzNC4zNTIgMTYuMiA0Ny43IDkuNzQ2IDkuNzQ2IDE5IDE4Ljk1IDE5IDE4Ljk1LTIuNSAzLjA2Ny01LjIgNi4wNjctOC4xIDktLjcuODMzLTEuNDgzIDEuNi0yLjM1IDIuMy0yLjUzMyAyLjUtNS4xNjcgNC44MTctNy45IDYuOTVsLTE3LjU1LTE3LjU1Yy0xNS41OTgtMTUuNi0yNy45OTYtMTcuMS00OC42LTE3LjEtMTkuNzcgMC0zMy4yMjMgMS44MjItNDcuNyAxNi4zLTguNjQ3IDguNjQ3LTE4LjU1IDE4LjYtMTguNTUgMTguNi0zLjc2Ny0yLjg2Ny03LjMzMy02LjAzNC0xMC43LTkuNS0yLjgtMi44LTUuNDE3LTUuNjY3LTcuODUtOC42IDAgMCA5Ljc5OC05Ljg0OCAxOS4xNS0xOS4yIDEzLjg1Mi0xMy44NTMgMTYuMS0yOS45MTYgMTYuMS00Ny44NSAwLTE3LjUtMi44NzQtMzMuODIzLTE1LjYtNDYuNTUtOC44MzUtOC44MzYtMjEuMDUtMjEtMjEuMDUtMjEgMi44MzMtMy42IDUuOTE3LTcuMDY3IDkuMjUtMTAuNCAyLjkzNC0yLjg2NyA1LjkzNC01LjU1IDktOC4wNUw2MS4xIDQzLjg1Qzc0LjEwMiA1Ni44NTIgOTAuNzY3IDYwLjIgMTA4LjcgNjAuMmMxOC40NjcgMCAzNS4wNzctMy41NzcgNDguNi0xNy4xIDguMzItOC4zMiAxOS4zLTE5LjI1IDE5LjMtMTkuMjUgMi45IDIuMzY3IDUuNzMzIDQuOTMzIDguNSA3LjcgMy40NjcgMy41MzMgNi42NSA3LjE4MyA5LjU1IDEwLjk1eiIgZmlsbD0iIzNBM0Y1MSIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgPGcgY2xpcC1ydWxlPSJldmVub2RkIj4KICAgIDxwYXRoIGQ9Ik03OC43IDExNGMtLjItMS4xNjctLjMzMi0yLjM1LS40LTMuNTUtLjAzMi0uNjY3LS4wNS0xLjMzMy0uMDUtMiAwLS43LjAxOC0xLjM2Ny4wNS0yIDAtLjA2Ny4wMTgtLjEzMy4wNS0uMi40MzUtNy4zNjcgMy4zMzQtMTMuNzMzIDguNy0xOS4xIDUuOS01LjgzMyAxMi45ODQtOC43NSAyMS4yNS04Ljc1IDguMyAwIDE1LjM4NCAyLjkxNyAyMS4yNSA4Ljc1IDUuODM0IDUuOTM0IDguNzUgMTMuMDMzIDguNzUgMjEuMyAwIDguMjY3LTIuOTE2IDE1LjM1LTguNzUgMjEuMjUtLjIuMjMzLS40MTYuNDUtLjY1LjY1LS45NjYuOTMzLTEuOTgyIDEuNzgzLTMuMDUgMi41NS01LjA2NSAzLjczMy0xMC45MTYgNS42LTE3LjU1IDUuNnMtMTIuNDY2LTEuODY2LTE3LjUtNS42Yy0xLjMzMi0uOTM0LTIuNTgyLTItMy43NS0zLjItNC41MzItNC41LTcuMzE2LTkuNzM0LTguMzUtMTUuN3oiIGZpbGw9IiMwQ0YiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogICAgPHBhdGggZD0iTTE1Ny44IDU5Ljc1bC0xNSAxNC42NU0zMC43ODUgMzIuNTI2TDcxLjY1IDczLjI1bTg0LjYgODQuMjVsMjcuODA4IDI4Ljc4bTEuODU1LTE1My44OTRMMTU3LjggNTkuNzVtLTEyNS40NSAxMjZsMjcuMzUtMjcuNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMENGIiBzdHJva2UtbWl0ZXJsaW1pdD0iMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICA8cGF0aCBkPSJNMTU3LjggNTkuNzVsLTE2Ljk1IDE3LjJNNTguOTcgNjAuNjA0bDE3LjIgMTcuMTVNNTkuNjIzIDE1OC40M2wxNi43NS0xNy40bTYxLjkyOC0xLjM5NmwxOC4wMjggMTcuOTQ1IiBmaWxsPSJub25lIiBzdHJva2U9IiMwQ0YiIHN0cm9rZS1taXRlcmxpbWl0PSIxIiBzdHJva2Utd2lkdGg9IjciLz4KICA8L2c+Cjwvc3ZnPg==';
|
||||
|
||||
function LoadingPage() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<img
|
||||
className={styles.logoFull}
|
||||
src={sonarrLogo}
|
||||
/>
|
||||
<LoadingMessage />
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingPage;
|
19
frontend/src/Components/Page/LoadingPage.tsx
Normal file
19
frontend/src/Components/Page/LoadingPage.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import LoadingMessage from 'Components/Loading/LoadingMessage';
|
||||
import styles from './LoadingPage.css';
|
||||
|
||||
const sonarrLogo =
|
||||
'data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjIxNi45IiB2aWV3Qm94PSIwIDAgMjE2LjcgMjE2LjkiIHdpZHRoPSIyMTYuNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMTYuNyAxMDguNDVjMCAyOS44MzMtMTAuNTMzIDU1LjQtMzEuNiA3Ni43LS43LjgzMy0xLjQ4MyAxLjYtMi4zNSAyLjMtMy40NjYgMy40LTcuMTMzIDYuNDg0LTExIDkuMjUtMTguMjY3IDEzLjQ2Ny0zOS4zNjcgMjAuMi02My4zIDIwLjItMjMuOTY3IDAtNDUuMDMzLTYuNzMzLTYzLjItMjAuMi00LjgtMy40LTkuMy03LjI1LTEzLjUtMTEuNTUtMTYuMzY3LTE2LjI2Ni0yNi40MTctMzUuMTY3LTMwLjE1LTU2LjctLjczMy00LjItMS4yMTctOC40NjctMS40NS0xMi44LS4xLTIuNC0uMTUtNC44LS4xNS03LjIgMC0yLjUzMy4wNS00Ljk1LjE1LTcuMjUgMC0uMjMzLjA2Ni0uNDY3LjItLjcgMS41NjctMjYuNiAxMi4wMzMtNDkuNTgzIDMxLjQtNjguOTVDNTMuMDUgMTAuNTE3IDc4LjYxNyAwIDEwOC40NSAwYzI5LjkzMyAwIDU1LjQ4NCAxMC41MTcgNzYuNjUgMzEuNTUgMjEuMDY3IDIxLjQzMyAzMS42IDQ3LjA2NyAzMS42IDc2Ljl6IiBmaWxsPSIjRUVFIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KICA8cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOTQuNjUgNDIuNWwtMjIuNCAyMi40QzE1OS4xNTIgNzcuOTk4IDE1OCA4OS40IDE1OCAxMDkuNWMwIDE3LjkzNCAyLjg1MiAzNC4zNTIgMTYuMiA0Ny43IDkuNzQ2IDkuNzQ2IDE5IDE4Ljk1IDE5IDE4Ljk1LTIuNSAzLjA2Ny01LjIgNi4wNjctOC4xIDktLjcuODMzLTEuNDgzIDEuNi0yLjM1IDIuMy0yLjUzMyAyLjUtNS4xNjcgNC44MTctNy45IDYuOTVsLTE3LjU1LTE3LjU1Yy0xNS41OTgtMTUuNi0yNy45OTYtMTcuMS00OC42LTE3LjEtMTkuNzcgMC0zMy4yMjMgMS44MjItNDcuNyAxNi4zLTguNjQ3IDguNjQ3LTE4LjU1IDE4LjYtMTguNTUgMTguNi0zLjc2Ny0yLjg2Ny03LjMzMy02LjAzNC0xMC43LTkuNS0yLjgtMi44LTUuNDE3LTUuNjY3LTcuODUtOC42IDAgMCA5Ljc5OC05Ljg0OCAxOS4xNS0xOS4yIDEzLjg1Mi0xMy44NTMgMTYuMS0yOS45MTYgMTYuMS00Ny44NSAwLTE3LjUtMi44NzQtMzMuODIzLTE1LjYtNDYuNTUtOC44MzUtOC44MzYtMjEuMDUtMjEtMjEuMDUtMjEgMi44MzMtMy42IDUuOTE3LTcuMDY3IDkuMjUtMTAuNCAyLjkzNC0yLjg2NyA1LjkzNC01LjU1IDktOC4wNUw2MS4xIDQzLjg1Qzc0LjEwMiA1Ni44NTIgOTAuNzY3IDYwLjIgMTA4LjcgNjAuMmMxOC40NjcgMCAzNS4wNzctMy41NzcgNDguNi0xNy4xIDguMzItOC4zMiAxOS4zLTE5LjI1IDE5LjMtMTkuMjUgMi45IDIuMzY3IDUuNzMzIDQuOTMzIDguNSA3LjcgMy40NjcgMy41MzMgNi42NSA3LjE4MyA5LjU1IDEwLjk1eiIgZmlsbD0iIzNBM0Y1MSIgZmlsbC1ydWxlPSJldmVub2RkIi8+CiAgPGcgY2xpcC1ydWxlPSJldmVub2RkIj4KICAgIDxwYXRoIGQ9Ik03OC43IDExNGMtLjItMS4xNjctLjMzMi0yLjM1LS40LTMuNTUtLjAzMi0uNjY3LS4wNS0xLjMzMy0uMDUtMiAwLS43LjAxOC0xLjM2Ny4wNS0yIDAtLjA2Ny4wMTgtLjEzMy4wNS0uMi40MzUtNy4zNjcgMy4zMzQtMTMuNzMzIDguNy0xOS4xIDUuOS01LjgzMyAxMi45ODQtOC43NSAyMS4yNS04Ljc1IDguMyAwIDE1LjM4NCAyLjkxNyAyMS4yNSA4Ljc1IDUuODM0IDUuOTM0IDguNzUgMTMuMDMzIDguNzUgMjEuMyAwIDguMjY3LTIuOTE2IDE1LjM1LTguNzUgMjEuMjUtLjIuMjMzLS40MTYuNDUtLjY1LjY1LS45NjYuOTMzLTEuOTgyIDEuNzgzLTMuMDUgMi41NS01LjA2NSAzLjczMy0xMC45MTYgNS42LTE3LjU1IDUuNnMtMTIuNDY2LTEuODY2LTE3LjUtNS42Yy0xLjMzMi0uOTM0LTIuNTgyLTItMy43NS0zLjItNC41MzItNC41LTcuMzE2LTkuNzM0LTguMzUtMTUuN3oiIGZpbGw9IiMwQ0YiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogICAgPHBhdGggZD0iTTE1Ny44IDU5Ljc1bC0xNSAxNC42NU0zMC43ODUgMzIuNTI2TDcxLjY1IDczLjI1bTg0LjYgODQuMjVsMjcuODA4IDI4Ljc4bTEuODU1LTE1My44OTRMMTU3LjggNTkuNzVtLTEyNS40NSAxMjZsMjcuMzUtMjcuNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMENGIiBzdHJva2UtbWl0ZXJsaW1pdD0iMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICA8cGF0aCBkPSJNMTU3LjggNTkuNzVsLTE2Ljk1IDE3LjJNNTguOTcgNjAuNjA0bDE3LjIgMTcuMTVNNTkuNjIzIDE1OC40M2wxNi43NS0xNy40bTYxLjkyOC0xLjM5NmwxOC4wMjggMTcuOTQ1IiBmaWxsPSJub25lIiBzdHJva2U9IiMwQ0YiIHN0cm9rZS1taXRlcmxpbWl0PSIxIiBzdHJva2Utd2lkdGg9IjciLz4KICA8L2c+Cjwvc3ZnPg==';
|
||||
|
||||
function LoadingPage() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<img className={styles.logoFull} src={sonarrLogo} />
|
||||
<LoadingMessage />
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingPage;
|
@ -1,142 +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}
|
||||
/>
|
||||
|
||||
<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 />
|
||||
|
||||
<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,308 +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 { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
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 = 'sonarrTest';
|
||||
|
||||
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.series.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.importLists.isPopulated,
|
||||
(state) => state.settings.indexerFlags.isPopulated,
|
||||
(state) => state.system.status.isPopulated,
|
||||
(state) => state.app.translations.isPopulated,
|
||||
(
|
||||
seriesIsPopulated,
|
||||
customFiltersIsPopulated,
|
||||
tagsIsPopulated,
|
||||
uiSettingsIsPopulated,
|
||||
qualityProfilesIsPopulated,
|
||||
languagesIsPopulated,
|
||||
importListsIsPopulated,
|
||||
indexerFlagsIsPopulated,
|
||||
systemStatusIsPopulated,
|
||||
translationsIsPopulated
|
||||
) => {
|
||||
return (
|
||||
seriesIsPopulated &&
|
||||
customFiltersIsPopulated &&
|
||||
tagsIsPopulated &&
|
||||
uiSettingsIsPopulated &&
|
||||
qualityProfilesIsPopulated &&
|
||||
languagesIsPopulated &&
|
||||
importListsIsPopulated &&
|
||||
indexerFlagsIsPopulated &&
|
||||
systemStatusIsPopulated &&
|
||||
translationsIsPopulated
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const selectErrors = createSelector(
|
||||
(state) => state.series.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.importLists.error,
|
||||
(state) => state.settings.indexerFlags.error,
|
||||
(state) => state.system.status.error,
|
||||
(state) => state.app.translations.error,
|
||||
(
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
uiSettingsError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
importListsError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
translationsError
|
||||
) => {
|
||||
const hasError = !!(
|
||||
seriesError ||
|
||||
customFiltersError ||
|
||||
tagsError ||
|
||||
uiSettingsError ||
|
||||
qualityProfilesError ||
|
||||
languagesError ||
|
||||
importListsError ||
|
||||
indexerFlagsError ||
|
||||
systemStatusError ||
|
||||
translationsError
|
||||
);
|
||||
|
||||
return {
|
||||
hasError,
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
uiSettingsError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
importListsError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
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 {
|
||||
dispatchFetchSeries() {
|
||||
dispatch(fetchSeries());
|
||||
},
|
||||
dispatchFetchCustomFilters() {
|
||||
dispatch(fetchCustomFilters());
|
||||
},
|
||||
dispatchFetchTags() {
|
||||
dispatch(fetchTags());
|
||||
},
|
||||
dispatchFetchQualityProfiles() {
|
||||
dispatch(fetchQualityProfiles());
|
||||
},
|
||||
dispatchFetchLanguages() {
|
||||
dispatch(fetchLanguages());
|
||||
},
|
||||
dispatchFetchImportLists() {
|
||||
dispatch(fetchImportLists());
|
||||
},
|
||||
dispatchFetchIndexerFlags() {
|
||||
dispatch(fetchIndexerFlags());
|
||||
},
|
||||
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.dispatchFetchSeries();
|
||||
this.props.dispatchFetchCustomFilters();
|
||||
this.props.dispatchFetchTags();
|
||||
this.props.dispatchFetchQualityProfiles();
|
||||
this.props.dispatchFetchLanguages();
|
||||
this.props.dispatchFetchImportLists();
|
||||
this.props.dispatchFetchIndexerFlags();
|
||||
this.props.dispatchFetchUISettings();
|
||||
this.props.dispatchFetchStatus();
|
||||
this.props.dispatchFetchTranslations();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSidebarToggle = () => {
|
||||
this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isPopulated,
|
||||
hasError,
|
||||
dispatchFetchSeries,
|
||||
dispatchFetchTags,
|
||||
dispatchFetchQualityProfiles,
|
||||
dispatchFetchLanguages,
|
||||
dispatchFetchImportLists,
|
||||
dispatchFetchIndexerFlags,
|
||||
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,
|
||||
dispatchFetchSeries: PropTypes.func.isRequired,
|
||||
dispatchFetchCustomFilters: PropTypes.func.isRequired,
|
||||
dispatchFetchTags: PropTypes.func.isRequired,
|
||||
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
|
||||
dispatchFetchLanguages: PropTypes.func.isRequired,
|
||||
dispatchFetchImportLists: PropTypes.func.isRequired,
|
||||
dispatchFetchIndexerFlags: 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.Sonarr.instanceName}` : window.Sonarr.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.Sonarr.instanceName}`
|
||||
: window.Sonarr.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,530 +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.SERIES_CONTINUING,
|
||||
title: () => translate('Series'),
|
||||
to: '/',
|
||||
alias: '/series',
|
||||
children: [
|
||||
{
|
||||
title: () => translate('AddNew'),
|
||||
to: '/add/new'
|
||||
},
|
||||
{
|
||||
title: () => translate('LibraryImport'),
|
||||
to: '/add/import'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
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('MetadataSource'),
|
||||
to: '/settings/metadatasource'
|
||||
},
|
||||
{
|
||||
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.Sonarr.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 === 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;
|
520
frontend/src/Components/Page/Sidebar/PageSidebar.tsx
Normal file
520
frontend/src/Components/Page/Sidebar/PageSidebar.tsx
Normal file
@ -0,0 +1,520 @@
|
||||
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.SERIES_CONTINUING,
|
||||
title: () => translate('Series'),
|
||||
to: '/',
|
||||
alias: '/series',
|
||||
children: [
|
||||
{
|
||||
title: () => translate('AddNew'),
|
||||
to: '/add/new',
|
||||
},
|
||||
{
|
||||
title: () => translate('LibraryImport'),
|
||||
to: '/add/import',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
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('MetadataSource'),
|
||||
to: '/settings/metadatasource',
|
||||
},
|
||||
{
|
||||
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.Sonarr.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,106 +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>
|
||||
}
|
||||
|
||||
<span className={isChildItem ? styles.noIcon : null}>
|
||||
{typeof title === 'function' ? title() : title}
|
||||
</span>
|
||||
|
||||
{
|
||||
!!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;
|
83
frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
Normal file
83
frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
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>
|
||||
)}
|
||||
|
||||
<span className={isChildItem ? styles.noIcon : undefined}>
|
||||
{typeof title === 'function' ? title() : title}
|
||||
</span>
|
||||
|
||||
{!!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,58 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './PageToolbarButton.css';
|
||||
|
||||
function PageToolbarButton(props) {
|
||||
const {
|
||||
label,
|
||||
iconName,
|
||||
spinningName,
|
||||
isDisabled,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.toolbarButton,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
name={isSpinning ? (spinningName || iconName) : iconName}
|
||||
isSpinning={isSpinning}
|
||||
size={21}
|
||||
/>
|
||||
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
PageToolbarButton.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
iconName: PropTypes.object.isRequired,
|
||||
spinningName: PropTypes.object,
|
||||
isSpinning: PropTypes.bool,
|
||||
isDisabled: PropTypes.bool,
|
||||
onPress: PropTypes.func
|
||||
};
|
||||
|
||||
PageToolbarButton.defaultProps = {
|
||||
spinningName: icons.SPINNER,
|
||||
isDisabled: false,
|
||||
isSpinning: false
|
||||
};
|
||||
|
||||
export default PageToolbarButton;
|
49
frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx
Normal file
49
frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon, { IconName } from 'Components/Icon';
|
||||
import Link, { LinkProps } from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './PageToolbarButton.css';
|
||||
|
||||
export interface PageToolbarButtonProps extends LinkProps {
|
||||
label: string;
|
||||
iconName: IconName;
|
||||
spinningName?: IconName;
|
||||
isSpinning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
overflowComponent?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
function PageToolbarButton({
|
||||
label,
|
||||
iconName,
|
||||
spinningName = icons.SPINNER,
|
||||
isDisabled = false,
|
||||
isSpinning = false,
|
||||
overflowComponent,
|
||||
...otherProps
|
||||
}: PageToolbarButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.toolbarButton,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
name={isSpinning ? spinningName || iconName : iconName}
|
||||
isSpinning={isSpinning}
|
||||
size={21}
|
||||
/>
|
||||
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageToolbarButton;
|
@ -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,53 +1,71 @@
|
||||
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');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
SERIES_SEARCH_INPUT: {
|
||||
key: 's',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsFocusSearchBox');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
SAVE_SETTINGS: {
|
||||
key: 'mod+s',
|
||||
get name() {
|
||||
return translate('KeyboardShortcutsSaveSettings');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
@ -58,15 +76,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);
|
||||
@ -81,13 +106,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) {
|
||||
@ -98,7 +127,7 @@ function keyboardShortcuts(WrappedComponent) {
|
||||
element.tagName === 'INPUT' ||
|
||||
element.tagName === 'SELECT' ||
|
||||
element.tagName === 'TEXTAREA' ||
|
||||
(element.contentEditable && element.contentEditable === 'true')
|
||||
('contentEditable' in element && element.contentEditable === 'true')
|
||||
);
|
||||
};
|
||||
|
||||
@ -116,9 +145,6 @@ function keyboardShortcuts(WrappedComponent) {
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
|
||||
KeyboardShortcuts.WrappedComponent = WrappedComponent;
|
||||
|
||||
return KeyboardShortcuts;
|
||||
}
|
||||
|
123
frontend/src/Helpers/Hooks/useAppPage.ts
Normal file
123
frontend/src/Helpers/Hooks/useAppPage.ts
Normal file
@ -0,0 +1,123 @@
|
||||
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 { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
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.series.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,
|
||||
(
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
uiSettingsError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
importListsError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
translationsError
|
||||
) => {
|
||||
const hasError = !!(
|
||||
seriesError ||
|
||||
customFiltersError ||
|
||||
tagsError ||
|
||||
uiSettingsError ||
|
||||
qualityProfilesError ||
|
||||
languagesError ||
|
||||
importListsError ||
|
||||
indexerFlagsError ||
|
||||
systemStatusError ||
|
||||
translationsError
|
||||
);
|
||||
|
||||
return {
|
||||
hasError,
|
||||
errors: {
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
uiSettingsError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
importListsError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
translationsError,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const useAppPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isPopulated = useSelector(
|
||||
(state: AppState) =>
|
||||
state.series.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 = 'sonarrTest';
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, key);
|
||||
localStorage.removeItem(key);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSeries());
|
||||
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');
|
||||
},
|
||||
},
|
||||
|
||||
focusSeriesSearchInput: {
|
||||
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;
|
@ -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';
|
||||
@ -21,7 +21,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 './SeriesIndexOverviewInfoRow.css';
|
||||
|
||||
interface SeriesIndexOverviewInfoRowProps {
|
||||
title?: string;
|
||||
iconName: IconProps['name'];
|
||||
iconName: IconName;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
|
@ -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 SeriesIndexSelectAllButtonProps {
|
||||
label: string;
|
||||
interface SeriesIndexSelectAllButtonProps
|
||||
extends Omit<PageToolbarButtonProps, 'iconName'> {
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
}
|
||||
|
||||
function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
|
||||
const { isSelectMode } = props;
|
||||
const { isSelectMode, overflowComponent } = props;
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const { allSelected, allUnselected } = selectState;
|
||||
|
||||
@ -33,6 +34,7 @@ function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
|
||||
<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 SeriesIndexSelectModeButtonProps {
|
||||
label: string;
|
||||
iconName: IconDefinition;
|
||||
interface SeriesIndexSelectModeButtonProps extends PageToolbarButtonProps {
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
|
||||
const { label, iconName, isSelectMode, onPress } = props;
|
||||
const { label, iconName, isSelectMode, overflowComponent, onPress } = props;
|
||||
const [, selectDispatch] = useSelect();
|
||||
|
||||
const onPressWrapper = useCallback(() => {
|
||||
@ -29,6 +27,7 @@ function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
|
||||
<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 SeriesIndexSelectModeMenuItemProps {
|
||||
label: string;
|
||||
iconName: IconDefinition;
|
||||
iconName: IconName;
|
||||
isSelectMode: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
@ -14,7 +14,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';
|
||||
@ -174,10 +174,11 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||
[setJumpToCharacter]
|
||||
);
|
||||
|
||||
const jumpBarItems = useMemo(() => {
|
||||
const jumpBarItems: PageJumpBarItems = useMemo(() => {
|
||||
// Reset if not sorting by sortTitle
|
||||
if (sortKey !== 'sortTitle') {
|
||||
return {
|
||||
characters: {},
|
||||
order: [],
|
||||
};
|
||||
}
|
||||
|
@ -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, fallbackErrorMessage = '') {
|
||||
if (!xhr || !xhr.responseJSON) {
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
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;
|
||||
}
|
@ -38,7 +38,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",
|
||||
@ -92,6 +92,7 @@
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@babel/preset-typescript": "7.26.0",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/mousetrap": "1.6.15",
|
||||
"@types/qs": "6.9.16",
|
||||
"@types/react-autosuggest": "10.1.11",
|
||||
"@types/react-document-title": "2.0.10",
|
||||
|
13
yarn.lock
13
yarn.lock
@ -1350,6 +1350,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"
|
||||
@ -3547,10 +3552,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…
x
Reference in New Issue
Block a user