mirror of
https://github.com/Sonarr/Sonarr
synced 2024-12-21 23:33:00 +00:00
Convert Page components to TypeScript
This commit is contained in:
parent
ab49268bac
commit
a500f4ed14
69 changed files with 2423 additions and 2755 deletions
|
@ -14,7 +14,6 @@ module.exports = (env) => {
|
||||||
const srcFolder = path.join(frontendFolder, 'src');
|
const srcFolder = path.join(frontendFolder, 'src');
|
||||||
const isProduction = !!env.production;
|
const isProduction = !!env.production;
|
||||||
const isProfiling = isProduction && !!env.profile;
|
const isProfiling = isProduction && !!env.profile;
|
||||||
const inlineWebWorkers = 'no-fallback';
|
|
||||||
|
|
||||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||||
|
|
||||||
|
@ -160,16 +159,6 @@ module.exports = (env) => {
|
||||||
|
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
|
||||||
test: /\.worker\.js$/,
|
|
||||||
use: {
|
|
||||||
loader: 'worker-loader',
|
|
||||||
options: {
|
|
||||||
filename: '[name].js',
|
|
||||||
inline: inlineWebWorkers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
test: [/\.jsx?$/, /\.tsx?$/],
|
test: [/\.jsx?$/, /\.tsx?$/],
|
||||||
exclude: /(node_modules|JsLibraries)/,
|
exclude: /(node_modules|JsLibraries)/,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import React from 'react';
|
||||||
import DocumentTitle from 'react-document-title';
|
import DocumentTitle from 'react-document-title';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import PageConnector from 'Components/Page/PageConnector';
|
import Page from 'Components/Page/Page';
|
||||||
import ApplyTheme from './ApplyTheme';
|
import ApplyTheme from './ApplyTheme';
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
|
|
||||||
|
@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ConnectedRouter history={history}>
|
<ConnectedRouter history={history}>
|
||||||
<ApplyTheme />
|
<ApplyTheme />
|
||||||
<PageConnector>
|
<Page>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</PageConnector>
|
</Page>
|
||||||
</ConnectedRouter>
|
</ConnectedRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { Error } from './AppSectionState';
|
||||||
import BlocklistAppState from './BlocklistAppState';
|
import BlocklistAppState from './BlocklistAppState';
|
||||||
import CalendarAppState from './CalendarAppState';
|
import CalendarAppState from './CalendarAppState';
|
||||||
import CaptchaAppState from './CaptchaAppState';
|
import CaptchaAppState from './CaptchaAppState';
|
||||||
import CommandAppState from './CommandAppState';
|
import CommandAppState from './CommandAppState';
|
||||||
|
import CustomFiltersAppState from './CustomFiltersAppState';
|
||||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||||
import EpisodesAppState from './EpisodesAppState';
|
import EpisodesAppState from './EpisodesAppState';
|
||||||
import HistoryAppState from './HistoryAppState';
|
import HistoryAppState from './HistoryAppState';
|
||||||
|
@ -52,7 +54,9 @@ export interface CustomFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSectionState {
|
export interface AppSectionState {
|
||||||
|
isUpdated: boolean;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
isDisconnected: boolean;
|
||||||
isReconnecting: boolean;
|
isReconnecting: boolean;
|
||||||
isSidebarVisible: boolean;
|
isSidebarVisible: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
|
@ -63,6 +67,10 @@ export interface AppSectionState {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
translations: {
|
||||||
|
error?: Error;
|
||||||
|
isPopulated: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
|
@ -71,6 +79,7 @@ interface AppState {
|
||||||
calendar: CalendarAppState;
|
calendar: CalendarAppState;
|
||||||
captcha: CaptchaAppState;
|
captcha: CaptchaAppState;
|
||||||
commands: CommandAppState;
|
commands: CommandAppState;
|
||||||
|
customFilters: CustomFiltersAppState;
|
||||||
episodeFiles: EpisodeFilesAppState;
|
episodeFiles: EpisodeFilesAppState;
|
||||||
episodeHistory: HistoryAppState;
|
episodeHistory: HistoryAppState;
|
||||||
episodes: EpisodesAppState;
|
episodes: EpisodesAppState;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import StackTrace from 'stacktrace-js';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './ErrorBoundaryError.css';
|
import styles from './ErrorBoundaryError.css';
|
||||||
|
|
||||||
interface ErrorBoundaryErrorProps {
|
export interface ErrorBoundaryErrorProps {
|
||||||
className: string;
|
className: string;
|
||||||
messageClassName: string;
|
messageClassName: string;
|
||||||
detailsClassName: string;
|
detailsClassName: string;
|
||||||
|
|
|
@ -8,13 +8,15 @@ import { kinds } from 'Helpers/Props';
|
||||||
import { Kind } from 'Helpers/Props/kinds';
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
import styles from './Icon.css';
|
import styles from './Icon.css';
|
||||||
|
|
||||||
|
export type IconName = FontAwesomeIconProps['icon'];
|
||||||
|
|
||||||
export interface IconProps
|
export interface IconProps
|
||||||
extends Omit<
|
extends Omit<
|
||||||
FontAwesomeIconProps,
|
FontAwesomeIconProps,
|
||||||
'icon' | 'spin' | 'name' | 'title' | 'size'
|
'icon' | 'spin' | 'name' | 'title' | 'size'
|
||||||
> {
|
> {
|
||||||
containerClassName?: ComponentProps<'span'>['className'];
|
containerClassName?: ComponentProps<'span'>['className'];
|
||||||
name: FontAwesomeIconProps['icon'];
|
name: IconName;
|
||||||
kind?: Extract<Kind, keyof typeof styles>;
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
size?: number;
|
size?: number;
|
||||||
isSpinning?: FontAwesomeIconProps['spin'];
|
isSpinning?: FontAwesomeIconProps['spin'];
|
||||||
|
|
|
@ -52,6 +52,7 @@ function ToolbarMenuButton(props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarMenuButton.propTypes = {
|
ToolbarMenuButton.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
iconName: PropTypes.object.isRequired,
|
iconName: PropTypes.object.isRequired,
|
||||||
showIndicator: PropTypes.bool.isRequired,
|
showIndicator: PropTypes.bool.isRequired,
|
||||||
text: PropTypes.string
|
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 React from 'react';
|
||||||
import { shortcuts } from 'Components/keyboardShortcuts';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Shortcut, shortcuts } from 'Components/keyboardShortcuts';
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './KeyboardShortcutsModalContent.css';
|
import styles from './KeyboardShortcutsModalContent.css';
|
||||||
|
|
||||||
function getShortcuts() {
|
function getShortcuts() {
|
||||||
const allShortcuts = [];
|
const allShortcuts: Shortcut[] = [];
|
||||||
|
|
||||||
Object.keys(shortcuts).forEach((key) => {
|
Object.keys(shortcuts).forEach((key) => {
|
||||||
allShortcuts.push(shortcuts[key]);
|
allShortcuts.push(shortcuts[key]);
|
||||||
|
@ -19,7 +20,7 @@ function getShortcuts() {
|
||||||
return allShortcuts;
|
return allShortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getShortcutKey(combo, isOsx) {
|
function getShortcutKey(combo: string, isOsx: boolean) {
|
||||||
const comboMatch = combo.match(/(.+?)\+(.)/);
|
const comboMatch = combo.match(/(.+?)\+(.)/);
|
||||||
|
|
||||||
if (!comboMatch) {
|
if (!comboMatch) {
|
||||||
|
@ -37,55 +38,39 @@ function getShortcutKey(combo, isOsx) {
|
||||||
return `${osModifier} + ${key}`;
|
return `${osModifier} + ${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyboardShortcutsModalContent(props) {
|
interface KeyboardShortcutsModalContentProps {
|
||||||
const {
|
onModalClose: () => void;
|
||||||
isOsx,
|
}
|
||||||
onModalClose
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
|
function KeyboardShortcutsModalContent({
|
||||||
|
onModalClose,
|
||||||
|
}: KeyboardShortcutsModalContentProps) {
|
||||||
|
const { isOsx } = useSelector(createSystemStatusSelector());
|
||||||
const allShortcuts = getShortcuts();
|
const allShortcuts = getShortcuts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent onModalClose={onModalClose}>
|
<ModalContent onModalClose={onModalClose}>
|
||||||
<ModalHeader>
|
<ModalHeader>{translate('KeyboardShortcuts')}</ModalHeader>
|
||||||
{translate('KeyboardShortcuts')}
|
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
{
|
{allShortcuts.map((shortcut) => {
|
||||||
allShortcuts.map((shortcut) => {
|
return (
|
||||||
return (
|
<div key={shortcut.name} className={styles.shortcut}>
|
||||||
<div
|
<div className={styles.key}>
|
||||||
key={shortcut.name}
|
{getShortcutKey(shortcut.key, isOsx)}
|
||||||
className={styles.shortcut}
|
|
||||||
>
|
|
||||||
<div className={styles.key}>
|
|
||||||
{getShortcutKey(shortcut.key, isOsx)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{shortcut.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})
|
<div>{shortcut.name}</div>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||||
onPress={onModalClose}
|
|
||||||
>
|
|
||||||
{translate('Close')}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcutsModalContent.propTypes = {
|
|
||||||
isOsx: PropTypes.bool.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KeyboardShortcutsModalContent;
|
export default KeyboardShortcutsModalContent;
|
|
@ -1,17 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
|
||||||
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
createSystemStatusSelector(),
|
|
||||||
(systemStatus) => {
|
|
||||||
return {
|
|
||||||
isOsx: systemStatus.isOsx
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);
|
|
|
@ -1,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 Fuse from 'fuse.js';
|
||||||
|
import { SuggestedSeries } from './SeriesSearchInput';
|
||||||
|
|
||||||
const fuseOptions = {
|
const fuseOptions = {
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
|
@ -14,11 +17,11 @@ const fuseOptions = {
|
||||||
'tvMazeId',
|
'tvMazeId',
|
||||||
'imdbId',
|
'imdbId',
|
||||||
'tmdbId',
|
'tmdbId',
|
||||||
'tags.label'
|
'tags.label',
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSuggestions(series, value) {
|
function getSuggestions(series: SuggestedSeries[], value: string) {
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
|
|
||||||
|
@ -28,16 +31,14 @@ function getSuggestions(series, value) {
|
||||||
if (s.firstCharacter === value.toLowerCase()) {
|
if (s.firstCharacter === value.toLowerCase()) {
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
item: series[i],
|
item: series[i],
|
||||||
indices: [
|
indices: [[0, 0]],
|
||||||
[0, 0]
|
|
||||||
],
|
|
||||||
matches: [
|
matches: [
|
||||||
{
|
{
|
||||||
value: s.title,
|
value: s.title,
|
||||||
key: 'title'
|
key: 'title',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
refIndex: 0
|
refIndex: 0,
|
||||||
});
|
});
|
||||||
if (suggestions.length > limit) {
|
if (suggestions.length > limit) {
|
||||||
break;
|
break;
|
||||||
|
@ -52,21 +53,18 @@ function getSuggestions(series, value) {
|
||||||
return suggestions;
|
return suggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
onmessage = function(e) {
|
onmessage = function (e) {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { series, value } = e.data;
|
||||||
series,
|
|
||||||
value
|
|
||||||
} = e.data;
|
|
||||||
|
|
||||||
const suggestions = getSuggestions(series, value);
|
const suggestions = getSuggestions(series, value);
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
value,
|
value,
|
||||||
suggestions
|
suggestions,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.postMessage(results);
|
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 React from 'react';
|
||||||
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
|
import ErrorBoundaryError, {
|
||||||
|
ErrorBoundaryErrorProps,
|
||||||
|
} from 'Components/Error/ErrorBoundaryError';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import PageContentBody from './PageContentBody';
|
import PageContentBody from './PageContentBody';
|
||||||
import styles from './PageContentError.css';
|
import styles from './PageContentError.css';
|
||||||
|
|
||||||
function PageContentError(props) {
|
function PageContentError(props: ErrorBoundaryErrorProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<PageContentBody>
|
<PageContentBody>
|
|
@ -1,33 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './PageContentFooter.css';
|
|
||||||
|
|
||||||
class PageContentFooter extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
children
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageContentFooter.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
children: PropTypes.node.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageContentFooter.defaultProps = {
|
|
||||||
className: styles.contentFooter
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageContentFooter;
|
|
16
frontend/src/Components/Page/PageContentFooter.tsx
Normal file
16
frontend/src/Components/Page/PageContentFooter.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './PageContentFooter.css';
|
||||||
|
|
||||||
|
interface PageContentFooterProps {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageContentFooter({
|
||||||
|
className = styles.contentFooter,
|
||||||
|
children,
|
||||||
|
}: PageContentFooterProps) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageContentFooter;
|
|
@ -1,160 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Measure from 'Components/Measure';
|
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
|
||||||
import PageJumpBarItem from './PageJumpBarItem';
|
|
||||||
import styles from './PageJumpBar.css';
|
|
||||||
|
|
||||||
const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
|
|
||||||
|
|
||||||
class PageJumpBar extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
height: 0,
|
|
||||||
visibleItems: props.items.order
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.computeVisibleItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
|
||||||
return (
|
|
||||||
nextProps.items !== this.props.items ||
|
|
||||||
nextState.height !== this.state.height ||
|
|
||||||
nextState.visibleItems !== this.state.visibleItems
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
if (
|
|
||||||
prevProps.items !== this.props.items ||
|
|
||||||
prevState.height !== this.state.height
|
|
||||||
) {
|
|
||||||
this.computeVisibleItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
computeVisibleItems() {
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
minimumItems
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (!items) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
characters,
|
|
||||||
order
|
|
||||||
} = items;
|
|
||||||
|
|
||||||
const height = this.state.height;
|
|
||||||
const maximumItems = Math.floor(height / ITEM_HEIGHT);
|
|
||||||
const diff = order.length - maximumItems;
|
|
||||||
|
|
||||||
if (diff < 0) {
|
|
||||||
this.setState({ visibleItems: order });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.length < minimumItems) {
|
|
||||||
this.setState({ visibleItems: order });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get first, last, and most common in between to make up numbers
|
|
||||||
const visibleItems = [order[0]];
|
|
||||||
|
|
||||||
const sorted = order.slice(1, -1).map((x) => characters[x]).sort((a, b) => b - a);
|
|
||||||
const minCount = sorted[maximumItems - 3];
|
|
||||||
const greater = sorted.reduce((acc, value) => acc + (value > minCount ? 1 : 0), 0);
|
|
||||||
let minAllowed = maximumItems - 2 - greater;
|
|
||||||
|
|
||||||
for (let i = 1; i < order.length - 1; i++) {
|
|
||||||
if (characters[order[i]] > minCount) {
|
|
||||||
visibleItems.push(order[i]);
|
|
||||||
} else if (characters[order[i]] === minCount && minAllowed > 0) {
|
|
||||||
visibleItems.push(order[i]);
|
|
||||||
minAllowed--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleItems.push(order[order.length - 1]);
|
|
||||||
|
|
||||||
this.setState({ visibleItems });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onMeasure = ({ height }) => {
|
|
||||||
if (height > 0) {
|
|
||||||
this.setState({ height });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
minimumItems,
|
|
||||||
onItemPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
visibleItems
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
if (!visibleItems.length || visibleItems.length < minimumItems) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.jumpBar}>
|
|
||||||
<Measure
|
|
||||||
whitelist={['height']}
|
|
||||||
onMeasure={this.onMeasure}
|
|
||||||
>
|
|
||||||
<div className={styles.jumpBarItems}>
|
|
||||||
{
|
|
||||||
visibleItems.map((item) => {
|
|
||||||
return (
|
|
||||||
<PageJumpBarItem
|
|
||||||
key={item}
|
|
||||||
label={item}
|
|
||||||
onItemPress={onItemPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Measure>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageJumpBar.propTypes = {
|
|
||||||
items: PropTypes.object.isRequired,
|
|
||||||
minimumItems: PropTypes.number.isRequired,
|
|
||||||
onItemPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageJumpBar.defaultProps = {
|
|
||||||
minimumItems: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageJumpBar;
|
|
90
frontend/src/Components/Page/PageJumpBar.tsx
Normal file
90
frontend/src/Components/Page/PageJumpBar.tsx
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||||
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
import PageJumpBarItem, { PageJumpBarItemProps } from './PageJumpBarItem';
|
||||||
|
import styles from './PageJumpBar.css';
|
||||||
|
|
||||||
|
const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
|
||||||
|
|
||||||
|
export interface PageJumpBarItems {
|
||||||
|
characters: Record<string, number>;
|
||||||
|
order: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageJumpBarProps {
|
||||||
|
items: PageJumpBarItems;
|
||||||
|
minimumItems?: number;
|
||||||
|
onItemPress: PageJumpBarItemProps['onItemPress'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageJumpBar({
|
||||||
|
items,
|
||||||
|
minimumItems = 5,
|
||||||
|
onItemPress,
|
||||||
|
}: PageJumpBarProps) {
|
||||||
|
const [jumpBarRef, { height }] = useMeasure();
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
const { characters, order } = items;
|
||||||
|
|
||||||
|
const maximumItems = Math.floor(height / ITEM_HEIGHT);
|
||||||
|
const diff = order.length - maximumItems;
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.length < minimumItems) {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get first, last, and most common in between to make up numbers
|
||||||
|
const result = [order[0]];
|
||||||
|
|
||||||
|
const sorted = order
|
||||||
|
.slice(1, -1)
|
||||||
|
.map((x) => characters[x])
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
const minCount = sorted[maximumItems - 3];
|
||||||
|
const greater = sorted.reduce(
|
||||||
|
(acc, value) => acc + (value > minCount ? 1 : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let minAllowed = maximumItems - 2 - greater;
|
||||||
|
|
||||||
|
for (let i = 1; i < order.length - 1; i++) {
|
||||||
|
if (characters[order[i]] > minCount) {
|
||||||
|
result.push(order[i]);
|
||||||
|
} else if (characters[order[i]] === minCount && minAllowed > 0) {
|
||||||
|
result.push(order[i]);
|
||||||
|
minAllowed--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(order[order.length - 1]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [items, height, minimumItems]);
|
||||||
|
|
||||||
|
if (!items.order.length || items.order.length < minimumItems) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={jumpBarRef} className={styles.jumpBar}>
|
||||||
|
<div className={styles.jumpBarItems}>
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
return (
|
||||||
|
<PageJumpBarItem
|
||||||
|
key={item}
|
||||||
|
label={item}
|
||||||
|
onItemPress={onItemPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageJumpBar;
|
|
@ -1,40 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import styles from './PageJumpBarItem.css';
|
|
||||||
|
|
||||||
class PageJumpBarItem extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onPress = () => {
|
|
||||||
const {
|
|
||||||
label,
|
|
||||||
onItemPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
onItemPress(label);
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={styles.jumpBarItem}
|
|
||||||
onPress={this.onPress}
|
|
||||||
>
|
|
||||||
{this.props.label.toUpperCase()}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageJumpBarItem.propTypes = {
|
|
||||||
label: PropTypes.string.isRequired,
|
|
||||||
onItemPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageJumpBarItem;
|
|
22
frontend/src/Components/Page/PageJumpBarItem.tsx
Normal file
22
frontend/src/Components/Page/PageJumpBarItem.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import styles from './PageJumpBarItem.css';
|
||||||
|
|
||||||
|
export interface PageJumpBarItemProps {
|
||||||
|
label: string;
|
||||||
|
onItemPress: (label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageJumpBarItem({ label, onItemPress }: PageJumpBarItemProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onItemPress(label);
|
||||||
|
}, [label, onItemPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={styles.jumpBarItem} onPress={handlePress}>
|
||||||
|
{label.toUpperCase()}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageJumpBarItem;
|
|
@ -1,41 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
|
|
||||||
function PageSectionContent(props) {
|
|
||||||
const {
|
|
||||||
isFetching,
|
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
errorMessage,
|
|
||||||
children
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
if (isFetching) {
|
|
||||||
return (
|
|
||||||
<LoadingIndicator />
|
|
||||||
);
|
|
||||||
} else if (!isFetching && !!error) {
|
|
||||||
return (
|
|
||||||
<Alert kind={kinds.DANGER}>{errorMessage}</Alert>
|
|
||||||
);
|
|
||||||
} else if (isPopulated && !error) {
|
|
||||||
return (
|
|
||||||
<div>{children}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
PageSectionContent.propTypes = {
|
|
||||||
isFetching: PropTypes.bool.isRequired,
|
|
||||||
isPopulated: PropTypes.bool.isRequired,
|
|
||||||
error: PropTypes.object,
|
|
||||||
errorMessage: PropTypes.string.isRequired,
|
|
||||||
children: PropTypes.node.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageSectionContent;
|
|
36
frontend/src/Components/Page/PageSectionContent.tsx
Normal file
36
frontend/src/Components/Page/PageSectionContent.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
|
||||||
|
interface PageSectionContentProps {
|
||||||
|
isFetching: boolean;
|
||||||
|
isPopulated: boolean;
|
||||||
|
error?: object;
|
||||||
|
errorMessage: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageSectionContent({
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
children,
|
||||||
|
}: PageSectionContentProps) {
|
||||||
|
if (isFetching) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFetching && !!error) {
|
||||||
|
return <Alert kind={kinds.DANGER}>{errorMessage}</Alert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPopulated && !error) {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageSectionContent;
|
|
@ -1,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, { SyntheticEvent } from 'react';
|
||||||
import React from 'react';
|
import { IconName } from 'Components/Icon';
|
||||||
import MenuItem from 'Components/Menu/MenuItem';
|
import MenuItem from 'Components/Menu/MenuItem';
|
||||||
import SpinnerIcon from 'Components/SpinnerIcon';
|
import SpinnerIcon from 'Components/SpinnerIcon';
|
||||||
import styles from './PageToolbarOverflowMenuItem.css';
|
import styles from './PageToolbarOverflowMenuItem.css';
|
||||||
|
|
||||||
interface PageToolbarOverflowMenuItemProps {
|
interface PageToolbarOverflowMenuItemProps {
|
||||||
iconName: IconDefinition;
|
iconName: IconName;
|
||||||
spinningName?: IconDefinition;
|
spinningName?: IconName;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
isSpinning?: boolean;
|
isSpinning?: boolean;
|
||||||
showIndicator?: boolean;
|
showIndicator?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
onPress: () => void;
|
onPress?: (event: SyntheticEvent<Element, Event>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) {
|
function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) {
|
||||||
|
|
|
@ -1,207 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Measure from 'Components/Measure';
|
|
||||||
import Menu from 'Components/Menu/Menu';
|
|
||||||
import MenuContent from 'Components/Menu/MenuContent';
|
|
||||||
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
|
||||||
import { forEach } from 'Helpers/elementChildren';
|
|
||||||
import { align, icons } from 'Helpers/Props';
|
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem';
|
|
||||||
import styles from './PageToolbarSection.css';
|
|
||||||
|
|
||||||
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
|
|
||||||
const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
|
|
||||||
const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
|
|
||||||
|
|
||||||
function calculateOverflowItems(children, isMeasured, width, collapseButtons) {
|
|
||||||
let buttonCount = 0;
|
|
||||||
let separatorCount = 0;
|
|
||||||
const validChildren = [];
|
|
||||||
|
|
||||||
forEach(children, (child) => {
|
|
||||||
if (Object.keys(child.props).length === 0) {
|
|
||||||
separatorCount++;
|
|
||||||
} else {
|
|
||||||
buttonCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
validChildren.push(child);
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonsWidth = buttonCount * BUTTON_WIDTH;
|
|
||||||
const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
|
|
||||||
const totalWidth = buttonsWidth + separatorsWidth;
|
|
||||||
|
|
||||||
// If the width of buttons and separators is less than
|
|
||||||
// the available width return all valid children.
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isMeasured ||
|
|
||||||
!collapseButtons ||
|
|
||||||
totalWidth < width
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
buttons: validChildren,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1);
|
|
||||||
const buttons = [];
|
|
||||||
const overflowItems = [];
|
|
||||||
let actualButtons = 0;
|
|
||||||
|
|
||||||
// Return all buttons if only one is being pushed to the overflow menu.
|
|
||||||
if (buttonCount - 1 === maxButtons) {
|
|
||||||
return {
|
|
||||||
buttons: validChildren,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
validChildren.forEach((child, index) => {
|
|
||||||
const isSeparator = Object.keys(child.props).length === 0;
|
|
||||||
|
|
||||||
if (actualButtons < maxButtons) {
|
|
||||||
if (!isSeparator) {
|
|
||||||
buttons.push(child);
|
|
||||||
actualButtons++;
|
|
||||||
}
|
|
||||||
} else if (!isSeparator) {
|
|
||||||
overflowItems.push(child.props);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
buttons,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class PageToolbarSection extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isMeasured: false,
|
|
||||||
width: 0,
|
|
||||||
buttons: [],
|
|
||||||
overflowItems: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onMeasure = ({ width }) => {
|
|
||||||
this.setState({
|
|
||||||
isMeasured: true,
|
|
||||||
width
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
children,
|
|
||||||
alignContent,
|
|
||||||
collapseButtons
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
isMeasured,
|
|
||||||
width
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const {
|
|
||||||
buttons,
|
|
||||||
buttonCount,
|
|
||||||
overflowItems
|
|
||||||
} = calculateOverflowItems(children, isMeasured, width, collapseButtons);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Measure
|
|
||||||
whitelist={['width']}
|
|
||||||
onMeasure={this.onMeasure}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={styles.sectionContainer}
|
|
||||||
style={{
|
|
||||||
flexGrow: buttonCount
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
isMeasured ?
|
|
||||||
<div className={classNames(
|
|
||||||
styles.section,
|
|
||||||
styles[alignContent]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
buttons.map((button) => {
|
|
||||||
return button;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!!overflowItems.length &&
|
|
||||||
<Menu>
|
|
||||||
<ToolbarMenuButton
|
|
||||||
className={styles.overflowMenuButton}
|
|
||||||
iconName={icons.OVERFLOW}
|
|
||||||
text={translate('More')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MenuContent>
|
|
||||||
{
|
|
||||||
overflowItems.map((item) => {
|
|
||||||
const {
|
|
||||||
label,
|
|
||||||
overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem
|
|
||||||
} = item;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverflowComponent
|
|
||||||
key={label}
|
|
||||||
{...item}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</MenuContent>
|
|
||||||
</Menu>
|
|
||||||
}
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Measure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
PageToolbarSection.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]),
|
|
||||||
collapseButtons: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
PageToolbarSection.defaultProps = {
|
|
||||||
alignContent: align.LEFT,
|
|
||||||
collapseButtons: true
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageToolbarSection;
|
|
151
frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx
Normal file
151
frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { ReactElement, useMemo } from 'react';
|
||||||
|
import Menu from 'Components/Menu/Menu';
|
||||||
|
import MenuContent from 'Components/Menu/MenuContent';
|
||||||
|
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
||||||
|
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import { Align } from 'Helpers/Props/align';
|
||||||
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import { PageToolbarButtonProps } from './PageToolbarButton';
|
||||||
|
import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem';
|
||||||
|
import styles from './PageToolbarSection.css';
|
||||||
|
|
||||||
|
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
|
||||||
|
const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
|
||||||
|
const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
|
||||||
|
|
||||||
|
interface PageToolbarSectionProps {
|
||||||
|
children?:
|
||||||
|
| (ReactElement<PageToolbarButtonProps> | ReactElement<never>)
|
||||||
|
| (ReactElement<PageToolbarButtonProps> | ReactElement<never>)[];
|
||||||
|
alignContent?: Extract<Align, keyof typeof styles>;
|
||||||
|
collapseButtons?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageToolbarSection({
|
||||||
|
children,
|
||||||
|
alignContent = 'left',
|
||||||
|
collapseButtons = true,
|
||||||
|
}: PageToolbarSectionProps) {
|
||||||
|
const [sectionRef, { width }] = useMeasure();
|
||||||
|
const isMeasured = width > 0;
|
||||||
|
|
||||||
|
const { buttons, buttonCount, overflowItems } = useMemo(() => {
|
||||||
|
let buttonCount = 0;
|
||||||
|
let separatorCount = 0;
|
||||||
|
const validChildren: ReactElement[] = [];
|
||||||
|
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!child) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(child.props).length === 0) {
|
||||||
|
separatorCount++;
|
||||||
|
} else {
|
||||||
|
buttonCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
validChildren.push(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonsWidth = buttonCount * BUTTON_WIDTH;
|
||||||
|
const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
|
||||||
|
const totalWidth = buttonsWidth + separatorsWidth;
|
||||||
|
|
||||||
|
// If the width of buttons and separators is less than
|
||||||
|
// the available width return all valid children.
|
||||||
|
|
||||||
|
if (!isMeasured || !collapseButtons || totalWidth < width) {
|
||||||
|
return {
|
||||||
|
buttons: validChildren,
|
||||||
|
buttonCount,
|
||||||
|
overflowItems: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxButtons = Math.max(
|
||||||
|
Math.floor((width - separatorsWidth) / BUTTON_WIDTH),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons: ReactElement<PageToolbarButtonProps>[] = [];
|
||||||
|
const overflowItems: PageToolbarButtonProps[] = [];
|
||||||
|
|
||||||
|
let actualButtons = 0;
|
||||||
|
|
||||||
|
// Return all buttons if only one is being pushed to the overflow menu.
|
||||||
|
if (buttonCount - 1 === maxButtons) {
|
||||||
|
const overflowItems: PageToolbarButtonProps[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttons: validChildren,
|
||||||
|
buttonCount,
|
||||||
|
overflowItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validChildren.forEach((child) => {
|
||||||
|
const isSeparator = Object.keys(child.props).length === 0;
|
||||||
|
|
||||||
|
if (actualButtons < maxButtons) {
|
||||||
|
if (!isSeparator) {
|
||||||
|
buttons.push(child);
|
||||||
|
actualButtons++;
|
||||||
|
}
|
||||||
|
} else if (!isSeparator) {
|
||||||
|
overflowItems.push(child.props);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttons,
|
||||||
|
buttonCount,
|
||||||
|
overflowItems,
|
||||||
|
};
|
||||||
|
}, [children, isMeasured, width, collapseButtons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sectionRef}
|
||||||
|
className={styles.sectionContainer}
|
||||||
|
style={{
|
||||||
|
flexGrow: buttonCount,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMeasured ? (
|
||||||
|
<div className={classNames(styles.section, styles[alignContent])}>
|
||||||
|
{buttons.map((button) => {
|
||||||
|
return button;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{overflowItems.length ? (
|
||||||
|
<Menu>
|
||||||
|
<ToolbarMenuButton
|
||||||
|
className={styles.overflowMenuButton}
|
||||||
|
iconName={icons.OVERFLOW}
|
||||||
|
text={translate('More')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MenuContent>
|
||||||
|
{overflowItems.map((item) => {
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
overflowComponent:
|
||||||
|
OverflowComponent = PageToolbarOverflowMenuItem,
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
return <OverflowComponent key={label} {...item} />;
|
||||||
|
})}
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageToolbarSection;
|
|
@ -1,17 +0,0 @@
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './PageToolbarSeparator.css';
|
|
||||||
|
|
||||||
class PageToolbarSeparator extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className={styles.separator} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PageToolbarSeparator;
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './PageToolbarSeparator.css';
|
||||||
|
|
||||||
|
function PageToolbarSeparator() {
|
||||||
|
return <div className={styles.separator} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageToolbarSeparator;
|
|
@ -11,8 +11,8 @@ interface OverlayScrollerProps {
|
||||||
trackClassName?: string;
|
trackClassName?: string;
|
||||||
scrollTop?: number;
|
scrollTop?: number;
|
||||||
scrollDirection: ScrollDirection;
|
scrollDirection: ScrollDirection;
|
||||||
autoHide: boolean;
|
autoHide?: boolean;
|
||||||
autoScroll: boolean;
|
autoScroll?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onScroll?: (payload: OnScroll) => void;
|
onScroll?: (payload: OnScroll) => void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import Icon, { IconProps } from './Icon';
|
import Icon, { IconName, IconProps } from './Icon';
|
||||||
|
|
||||||
export interface SpinnerIconProps extends IconProps {
|
export interface SpinnerIconProps extends IconProps {
|
||||||
spinningName?: IconProps['name'];
|
spinningName?: IconName;
|
||||||
isSpinning: Required<IconProps['isSpinning']>;
|
isSpinning: Required<IconProps['isSpinning']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,53 +1,71 @@
|
||||||
import Mousetrap from 'mousetrap';
|
import Mousetrap, { MousetrapInstance } from 'mousetrap';
|
||||||
import React, { Component } from 'react';
|
import React, { Component, ComponentType } from 'react';
|
||||||
import getDisplayName from 'Helpers/getDisplayName';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
export const shortcuts = {
|
export interface Shortcut {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BindingOptions {
|
||||||
|
isGlobal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyboardShortcutsProps {
|
||||||
|
bindShortcut: (
|
||||||
|
key: string,
|
||||||
|
callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void,
|
||||||
|
options?: BindingOptions
|
||||||
|
) => void;
|
||||||
|
unbindShortcut: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shortcuts: Record<string, Shortcut> = {
|
||||||
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
|
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
|
||||||
key: '?',
|
key: '?',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsOpenModal');
|
return translate('KeyboardShortcutsOpenModal');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
CLOSE_MODAL: {
|
CLOSE_MODAL: {
|
||||||
key: 'Esc',
|
key: 'Esc',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsCloseModal');
|
return translate('KeyboardShortcutsCloseModal');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
ACCEPT_CONFIRM_MODAL: {
|
ACCEPT_CONFIRM_MODAL: {
|
||||||
key: 'Enter',
|
key: 'Enter',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsConfirmModal');
|
return translate('KeyboardShortcutsConfirmModal');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
SERIES_SEARCH_INPUT: {
|
SERIES_SEARCH_INPUT: {
|
||||||
key: 's',
|
key: 's',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsFocusSearchBox');
|
return translate('KeyboardShortcutsFocusSearchBox');
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
SAVE_SETTINGS: {
|
SAVE_SETTINGS: {
|
||||||
key: 'mod+s',
|
key: 'mod+s',
|
||||||
get name() {
|
get name() {
|
||||||
return translate('KeyboardShortcutsSaveSettings');
|
return translate('KeyboardShortcutsSaveSettings');
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function keyboardShortcuts(WrappedComponent) {
|
function keyboardShortcuts(
|
||||||
|
WrappedComponent: ComponentType<KeyboardShortcutsProps>
|
||||||
|
) {
|
||||||
class KeyboardShortcuts extends Component {
|
class KeyboardShortcuts extends Component {
|
||||||
|
|
||||||
//
|
//
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props: never) {
|
||||||
super(props, context);
|
super(props);
|
||||||
this._mousetrapBindings = {};
|
this._mousetrapBindings = {};
|
||||||
this._mousetrap = new Mousetrap();
|
this._mousetrap = new Mousetrap();
|
||||||
this._mousetrap.stopCallback = this.stopCallback;
|
this._mousetrap.stopCallback = this.stopCallback;
|
||||||
|
@ -58,15 +76,22 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
this._mousetrap = null;
|
this._mousetrap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_mousetrap: MousetrapInstance | null;
|
||||||
|
_mousetrapBindings: Record<string, BindingOptions>;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Control
|
// Control
|
||||||
|
|
||||||
bindShortcut = (key, callback, options = {}) => {
|
bindShortcut = (
|
||||||
this._mousetrap.bind(key, callback);
|
key: string,
|
||||||
|
callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void,
|
||||||
|
options: BindingOptions = {}
|
||||||
|
) => {
|
||||||
|
this._mousetrap?.bind(key, callback);
|
||||||
this._mousetrapBindings[key] = options;
|
this._mousetrapBindings[key] = options;
|
||||||
};
|
};
|
||||||
|
|
||||||
unbindShortcut = (key) => {
|
unbindShortcut = (key: string) => {
|
||||||
if (this._mousetrap != null) {
|
if (this._mousetrap != null) {
|
||||||
delete this._mousetrapBindings[key];
|
delete this._mousetrapBindings[key];
|
||||||
this._mousetrap.unbind(key);
|
this._mousetrap.unbind(key);
|
||||||
|
@ -81,13 +106,17 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
keys.forEach((binding) => {
|
keys.forEach((binding) => {
|
||||||
this._mousetrap.unbind(binding);
|
this._mousetrap?.unbind(binding);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._mousetrapBindings = {};
|
this._mousetrapBindings = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
stopCallback = (event, element, combo) => {
|
stopCallback = (
|
||||||
|
_e: Mousetrap.ExtendedKeyboardEvent,
|
||||||
|
element: Element,
|
||||||
|
combo: string
|
||||||
|
) => {
|
||||||
const binding = this._mousetrapBindings[combo];
|
const binding = this._mousetrapBindings[combo];
|
||||||
|
|
||||||
if (!binding || binding.isGlobal) {
|
if (!binding || binding.isGlobal) {
|
||||||
|
@ -98,7 +127,7 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
element.tagName === 'INPUT' ||
|
element.tagName === 'INPUT' ||
|
||||||
element.tagName === 'SELECT' ||
|
element.tagName === 'SELECT' ||
|
||||||
element.tagName === 'TEXTAREA' ||
|
element.tagName === 'TEXTAREA' ||
|
||||||
(element.contentEditable && element.contentEditable === 'true')
|
('contentEditable' in element && element.contentEditable === 'true')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -116,9 +145,6 @@ function keyboardShortcuts(WrappedComponent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
|
|
||||||
KeyboardShortcuts.WrappedComponent = WrappedComponent;
|
|
||||||
|
|
||||||
return KeyboardShortcuts;
|
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 React, { useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { IconName } from 'Components/Icon';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
import dimensions from 'Styles/Variables/dimensions';
|
import dimensions from 'Styles/Variables/dimensions';
|
||||||
|
@ -21,7 +21,7 @@ interface RowProps {
|
||||||
|
|
||||||
interface RowInfoProps {
|
interface RowInfoProps {
|
||||||
title: string;
|
title: string;
|
||||||
iconName: IconDefinition;
|
iconName: IconName;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon, { IconProps } from 'Components/Icon';
|
import Icon, { IconName } from 'Components/Icon';
|
||||||
import styles from './SeriesIndexOverviewInfoRow.css';
|
import styles from './SeriesIndexOverviewInfoRow.css';
|
||||||
|
|
||||||
interface SeriesIndexOverviewInfoRowProps {
|
interface SeriesIndexOverviewInfoRowProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
iconName: IconProps['name'];
|
iconName: IconName;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/SelectContext';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton, {
|
||||||
|
PageToolbarButtonProps,
|
||||||
|
} from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
interface SeriesIndexSelectAllButtonProps {
|
interface SeriesIndexSelectAllButtonProps
|
||||||
label: string;
|
extends Omit<PageToolbarButtonProps, 'iconName'> {
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
overflowComponent: React.FunctionComponent<never>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
|
function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
|
||||||
const { isSelectMode } = props;
|
const { isSelectMode, overflowComponent } = props;
|
||||||
const [selectState, selectDispatch] = useSelect();
|
const [selectState, selectDispatch] = useSelect();
|
||||||
const { allSelected, allUnselected } = selectState;
|
const { allSelected, allUnselected } = selectState;
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@ function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
|
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
|
||||||
iconName={icon}
|
iconName={icon}
|
||||||
|
overflowComponent={overflowComponent}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/SelectContext';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton, {
|
||||||
|
PageToolbarButtonProps,
|
||||||
|
} from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
|
|
||||||
interface SeriesIndexSelectModeButtonProps {
|
interface SeriesIndexSelectModeButtonProps extends PageToolbarButtonProps {
|
||||||
label: string;
|
|
||||||
iconName: IconDefinition;
|
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
overflowComponent: React.FunctionComponent<never>;
|
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
|
function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
|
||||||
const { label, iconName, isSelectMode, onPress } = props;
|
const { label, iconName, isSelectMode, overflowComponent, onPress } = props;
|
||||||
const [, selectDispatch] = useSelect();
|
const [, selectDispatch] = useSelect();
|
||||||
|
|
||||||
const onPressWrapper = useCallback(() => {
|
const onPressWrapper = useCallback(() => {
|
||||||
|
@ -29,6 +27,7 @@ function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={label}
|
label={label}
|
||||||
iconName={iconName}
|
iconName={iconName}
|
||||||
|
overflowComponent={overflowComponent}
|
||||||
onPress={onPressWrapper}
|
onPress={onPressWrapper}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/SelectContext';
|
||||||
|
import { IconName } from 'Components/Icon';
|
||||||
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
|
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
|
||||||
|
|
||||||
interface SeriesIndexSelectModeMenuItemProps {
|
interface SeriesIndexSelectModeMenuItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
iconName: IconDefinition;
|
iconName: IconName;
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import PageContent from 'Components/Page/PageContent';
|
import PageContent from 'Components/Page/PageContent';
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
import PageJumpBar from 'Components/Page/PageJumpBar';
|
import PageJumpBar, { PageJumpBarItems } from 'Components/Page/PageJumpBar';
|
||||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||||
|
@ -174,10 +174,11 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||||
[setJumpToCharacter]
|
[setJumpToCharacter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const jumpBarItems = useMemo(() => {
|
const jumpBarItems: PageJumpBarItems = useMemo(() => {
|
||||||
// Reset if not sorting by sortTitle
|
// Reset if not sorting by sortTitle
|
||||||
if (sortKey !== 'sortTitle') {
|
if (sortKey !== 'sortTitle') {
|
||||||
return {
|
return {
|
||||||
|
characters: {},
|
||||||
order: [],
|
order: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
|
import { Tag } from 'App/State/TagsAppState';
|
||||||
|
|
||||||
function createTagsSelector() {
|
function createTagsSelector(): (state: AppState) => Tag[] {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state: AppState) => state.tags.items,
|
(state: AppState) => state.tags.items,
|
||||||
(tags) => {
|
(tags) => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Error } from 'App/State/AppSectionState';
|
import { Error } from 'App/State/AppSectionState';
|
||||||
|
|
||||||
function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) {
|
function getErrorMessage(xhr: Error, fallbackErrorMessage = '') {
|
||||||
if (!xhr || !xhr.responseJSON) {
|
if (!xhr || !xhr.responseJSON) {
|
||||||
return fallbackErrorMessage;
|
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",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"element-class": "0.2.2",
|
"element-class": "0.2.2",
|
||||||
"filesize": "10.1.6",
|
"filesize": "10.1.6",
|
||||||
"fuse.js": "6.6.2",
|
"fuse.js": "7.0.0",
|
||||||
"history": "4.10.1",
|
"history": "4.10.1",
|
||||||
"jdu": "1.0.0",
|
"jdu": "1.0.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1",
|
||||||
|
@ -92,6 +92,7 @@
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
"@babel/preset-typescript": "7.26.0",
|
"@babel/preset-typescript": "7.26.0",
|
||||||
"@types/lodash": "4.14.195",
|
"@types/lodash": "4.14.195",
|
||||||
|
"@types/mousetrap": "1.6.15",
|
||||||
"@types/qs": "6.9.16",
|
"@types/qs": "6.9.16",
|
||||||
"@types/react-autosuggest": "10.1.11",
|
"@types/react-autosuggest": "10.1.11",
|
||||||
"@types/react-document-title": "2.0.10",
|
"@types/react-document-title": "2.0.10",
|
||||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -1350,6 +1350,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
|
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
|
||||||
integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==
|
integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==
|
||||||
|
|
||||||
|
"@types/mousetrap@1.6.15":
|
||||||
|
version "1.6.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86"
|
||||||
|
integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*":
|
||||||
version "22.7.5"
|
version "22.7.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b"
|
||||||
|
@ -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"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||||
|
|
||||||
fuse.js@6.6.2:
|
fuse.js@7.0.0:
|
||||||
version "6.6.2"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
|
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
|
||||||
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
|
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==
|
||||||
|
|
||||||
gensync@^1.0.0-beta.2:
|
gensync@^1.0.0-beta.2:
|
||||||
version "1.0.0-beta.2"
|
version "1.0.0-beta.2"
|
||||||
|
|
Loading…
Reference in a new issue