1
0
Fork 0
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:
Mark McDowall 2024-12-16 06:51:45 -08:00
parent ab49268bac
commit a500f4ed14
No known key found for this signature in database
69 changed files with 2423 additions and 2755 deletions

View file

@ -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)/,

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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'];

View file

@ -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

View file

@ -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);

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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);

View 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;

View file

@ -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);

View 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;

View file

@ -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);

View file

@ -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;

View 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;

View file

@ -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);

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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)
);

View file

@ -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;

View 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;

View file

@ -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>

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -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) {

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -0,0 +1,8 @@
import React from 'react';
import styles from './PageToolbarSeparator.css';
function PageToolbarSeparator() {
return <div className={styles.separator} />;
}
export default PageToolbarSeparator;

View file

@ -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;
} }

View file

@ -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']>;
} }

View file

@ -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;
} }

View 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;

View 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;

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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;

View file

@ -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}
/> />
); );

View file

@ -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;
} }

View file

@ -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: [],
}; };
} }

View file

@ -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) => {

View file

@ -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
View 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;
}

View file

@ -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",

View file

@ -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"