diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 0d0364950..1eb405510 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -14,7 +14,6 @@ module.exports = (env) => { const srcFolder = path.join(frontendFolder, 'src'); const isProduction = !!env.production; const isProfiling = isProduction && !!env.profile; - const inlineWebWorkers = 'no-fallback'; const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder); @@ -160,16 +159,6 @@ module.exports = (env) => { module: { rules: [ - { - test: /\.worker\.js$/, - use: { - loader: 'worker-loader', - options: { - filename: '[name].js', - inline: inlineWebWorkers - } - } - }, { test: [/\.jsx?$/, /\.tsx?$/], exclude: /(node_modules|JsLibraries)/, diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx index b71199bb3..e325c7184 100644 --- a/frontend/src/App/App.tsx +++ b/frontend/src/App/App.tsx @@ -4,7 +4,7 @@ import React from 'react'; import DocumentTitle from 'react-document-title'; import { Provider } from 'react-redux'; import { Store } from 'redux'; -import PageConnector from 'Components/Page/PageConnector'; +import Page from 'Components/Page/Page'; import ApplyTheme from './ApplyTheme'; import AppRoutes from './AppRoutes'; @@ -22,9 +22,9 @@ function App({ store, history }: AppProps) { - + - + diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 84bd5d0b4..4b2d32f16 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,7 +1,9 @@ +import { Error } from './AppSectionState'; import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; import CaptchaAppState from './CaptchaAppState'; import CommandAppState from './CommandAppState'; +import CustomFiltersAppState from './CustomFiltersAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import HistoryAppState from './HistoryAppState'; @@ -52,7 +54,9 @@ export interface CustomFilter { } export interface AppSectionState { + isUpdated: boolean; isConnected: boolean; + isDisconnected: boolean; isReconnecting: boolean; isSidebarVisible: boolean; version: string; @@ -63,6 +67,10 @@ export interface AppSectionState { width: number; height: number; }; + translations: { + error?: Error; + isPopulated: boolean; + }; } interface AppState { @@ -71,6 +79,7 @@ interface AppState { calendar: CalendarAppState; captcha: CaptchaAppState; commands: CommandAppState; + customFilters: CustomFiltersAppState; episodeFiles: EpisodeFilesAppState; episodeHistory: HistoryAppState; episodes: EpisodesAppState; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.tsx b/frontend/src/Components/Error/ErrorBoundaryError.tsx index 870b28058..ca4dd7015 100644 --- a/frontend/src/Components/Error/ErrorBoundaryError.tsx +++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx @@ -3,7 +3,7 @@ import StackTrace from 'stacktrace-js'; import translate from 'Utilities/String/translate'; import styles from './ErrorBoundaryError.css'; -interface ErrorBoundaryErrorProps { +export interface ErrorBoundaryErrorProps { className: string; messageClassName: string; detailsClassName: string; diff --git a/frontend/src/Components/Icon.tsx b/frontend/src/Components/Icon.tsx index a04463b51..131b0318c 100644 --- a/frontend/src/Components/Icon.tsx +++ b/frontend/src/Components/Icon.tsx @@ -8,13 +8,15 @@ import { kinds } from 'Helpers/Props'; import { Kind } from 'Helpers/Props/kinds'; import styles from './Icon.css'; +export type IconName = FontAwesomeIconProps['icon']; + export interface IconProps extends Omit< FontAwesomeIconProps, 'icon' | 'spin' | 'name' | 'title' | 'size' > { containerClassName?: ComponentProps<'span'>['className']; - name: FontAwesomeIconProps['icon']; + name: IconName; kind?: Extract; size?: number; isSpinning?: FontAwesomeIconProps['spin']; diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js index ba575753a..e193bce3f 100644 --- a/frontend/src/Components/Menu/ToolbarMenuButton.js +++ b/frontend/src/Components/Menu/ToolbarMenuButton.js @@ -52,6 +52,7 @@ function ToolbarMenuButton(props) { } ToolbarMenuButton.propTypes = { + className: PropTypes.string, iconName: PropTypes.object.isRequired, showIndicator: PropTypes.bool.isRequired, text: PropTypes.string diff --git a/frontend/src/Components/Modal/ConfirmModal.js b/frontend/src/Components/Modal/ConfirmModal.js deleted file mode 100644 index c129f29b3..000000000 --- a/frontend/src/Components/Modal/ConfirmModal.js +++ /dev/null @@ -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 ( - - - {title} - - - {message} - - - - { - !hideCancelButton && - - } - - - {confirmLabel} - - - - - ); -} - -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); diff --git a/frontend/src/Components/Modal/ConfirmModal.tsx b/frontend/src/Components/Modal/ConfirmModal.tsx new file mode 100644 index 000000000..2adf99a3a --- /dev/null +++ b/frontend/src/Components/Modal/ConfirmModal.tsx @@ -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 ( + + + {title} + + {message} + + + {!hideCancelButton && ( + + )} + + + {confirmLabel} + + + + + ); +} + +export default ConfirmModal; diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js deleted file mode 100644 index f76522454..000000000 --- a/frontend/src/Components/Page/ErrorPage.js +++ /dev/null @@ -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 ( -
-
- {errorMessage} -
- -
- {translate('VersionNumber', { version })} -
-
- ); -} - -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; diff --git a/frontend/src/Components/Page/ErrorPage.tsx b/frontend/src/Components/Page/ErrorPage.tsx new file mode 100644 index 000000000..22f2569e3 --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.tsx @@ -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 ( +
+
{errorMessage}
+ +
+ {translate('VersionNumber', { version })} +
+
+ ); +} + +export default ErrorPage; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js deleted file mode 100644 index 2e2e7331a..000000000 --- a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js +++ /dev/null @@ -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 ( - - - - ); -} - -KeyboardShortcutsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default KeyboardShortcutsModal; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.tsx b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.tsx new file mode 100644 index 000000000..b87b066da --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.tsx @@ -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 ( + + + + ); +} + +export default KeyboardShortcutsModal; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.tsx similarity index 52% rename from frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js rename to frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.tsx index 328e65420..42e655e71 100644 --- a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.tsx @@ -1,16 +1,17 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import { shortcuts } from 'Components/keyboardShortcuts'; +import { useSelector } from 'react-redux'; +import { Shortcut, shortcuts } from 'Components/keyboardShortcuts'; import Button from 'Components/Link/Button'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import translate from 'Utilities/String/translate'; import styles from './KeyboardShortcutsModalContent.css'; function getShortcuts() { - const allShortcuts = []; + const allShortcuts: Shortcut[] = []; Object.keys(shortcuts).forEach((key) => { allShortcuts.push(shortcuts[key]); @@ -19,7 +20,7 @@ function getShortcuts() { return allShortcuts; } -function getShortcutKey(combo, isOsx) { +function getShortcutKey(combo: string, isOsx: boolean) { const comboMatch = combo.match(/(.+?)\+(.)/); if (!comboMatch) { @@ -37,55 +38,39 @@ function getShortcutKey(combo, isOsx) { return `${osModifier} + ${key}`; } -function KeyboardShortcutsModalContent(props) { - const { - isOsx, - onModalClose - } = props; +interface KeyboardShortcutsModalContentProps { + onModalClose: () => void; +} +function KeyboardShortcutsModalContent({ + onModalClose, +}: KeyboardShortcutsModalContentProps) { + const { isOsx } = useSelector(createSystemStatusSelector()); const allShortcuts = getShortcuts(); return ( - - {translate('KeyboardShortcuts')} - + {translate('KeyboardShortcuts')} - { - allShortcuts.map((shortcut) => { - return ( -
-
- {getShortcutKey(shortcut.key, isOsx)} -
- -
- {shortcut.name} -
+ {allShortcuts.map((shortcut) => { + return ( +
+
+ {getShortcutKey(shortcut.key, isOsx)}
- ); - }) - } + +
{shortcut.name}
+
+ ); + })} - + ); } -KeyboardShortcutsModalContent.propTypes = { - isOsx: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - export default KeyboardShortcutsModalContent; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js deleted file mode 100644 index d80877153..000000000 --- a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js +++ /dev/null @@ -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); diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js deleted file mode 100644 index 7d77488b7..000000000 --- a/frontend/src/Components/Page/Header/PageHeader.js +++ /dev/null @@ -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 ( -
-
- - Sonarr Logo - -
- -
- -
- - - -
- - - -
- - -
- ); - } -} - -PageHeader.propTypes = { - onSidebarToggle: PropTypes.func.isRequired, - bindShortcut: PropTypes.func.isRequired -}; - -export default keyboardShortcuts(PageHeader); diff --git a/frontend/src/Components/Page/Header/PageHeader.tsx b/frontend/src/Components/Page/Header/PageHeader.tsx new file mode 100644 index 000000000..1f96d3b44 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.tsx @@ -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 ( +
+
+ + Sonarr Logo + +
+ +
+ +
+ + + +
+ + + +
+ + +
+ ); +} + +export default PageHeader; diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.js b/frontend/src/Components/Page/Header/SeriesSearchInput.js deleted file mode 100644 index a763a3f05..000000000 --- a/frontend/src/Components/Page/Header/SeriesSearchInput.js +++ /dev/null @@ -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 ( -
- {section.title} - - { - section.loading && - - } -
- ); - } - - getSuggestionValue({ title }) { - return title; - } - - renderSuggestion(item, { query }) { - if (item.type === ADD_NEW_TYPE) { - return ( -
- {translate('SearchForQuery', { query })} -
- ); - } - - return ( - - ); - } - - 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 ( -
- - - -
- ); - } -} - -SeriesSearchInput.propTypes = { - series: PropTypes.arrayOf(PropTypes.object).isRequired, - onGoToSeries: PropTypes.func.isRequired, - onGoToAddNewSeries: PropTypes.func.isRequired, - bindShortcut: PropTypes.func.isRequired -}; - -export default keyboardShortcuts(SeriesSearchInput); diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.tsx b/frontend/src/Components/Page/Header/SeriesSearchInput.tsx new file mode 100644 index 000000000..c10fcf740 --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInput.tsx @@ -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((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([]); + + const autosuggestRef = useRef(null); + const inputRef = useRef(null); + const worker = useRef(null); + const isLoading = useRef(false); + const requestValue = useRef(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 ( +
+ {section.title} + + {section.loading && ( + + )} +
+ ); + }, []); + + const getSuggestionValue = useCallback(({ title }: { title: string }) => { + return title; + }, []); + + const renderSuggestion = useCallback( + ( + item: AddNewSeriesSuggestion | SeriesSuggestion, + { query }: { query: string } + ) => { + if ('type' in item) { + return ( +
+ {translate('SearchForQuery', { query })} +
+ ); + } + + return ; + }, + [] + ); + + const handleChange = useCallback( + ( + _event: FormEvent, + { + newValue, + method, + }: { + newValue: string; + method: 'down' | 'up' | 'escape' | 'enter' | 'click' | 'type'; + } + ) => { + if (method === 'up' || method === 'down') { + return; + } + + setValue(newValue); + }, + [] + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + 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 ( +
+ + + +
+ ); +} + +export default SeriesSearchInput; diff --git a/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js deleted file mode 100644 index 6dab05693..000000000 --- a/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js +++ /dev/null @@ -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); diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.js b/frontend/src/Components/Page/Header/SeriesSearchResult.js deleted file mode 100644 index 6d1c76416..000000000 --- a/frontend/src/Components/Page/Header/SeriesSearchResult.js +++ /dev/null @@ -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 ( -
- - -
-
- {title} -
- - { - alternateTitle ? -
- {alternateTitle.title} -
: - null - } - - { - match.key === 'tvdbId' && tvdbId ? -
- TvdbId: {tvdbId} -
: - null - } - - { - match.key === 'tvMazeId' && tvMazeId ? -
- TvMazeId: {tvMazeId} -
: - null - } - - { - match.key === 'imdbId' && imdbId ? -
- ImdbId: {imdbId} -
: - null - } - - { - match.key === 'tmdbId' && tmdbId ? -
- TmdbId: {tmdbId} -
: - null - } - - { - tag ? -
- -
: - null - } -
-
- ); -} - -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; diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.tsx b/frontend/src/Components/Page/Header/SeriesSearchResult.tsx new file mode 100644 index 000000000..83feb693d --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchResult.tsx @@ -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 ( +
+ + +
+
{title}
+ + {alternateTitle ? ( +
{alternateTitle.title}
+ ) : null} + + {match.key === 'tvdbId' && tvdbId ? ( +
TvdbId: {tvdbId}
+ ) : null} + + {match.key === 'tvMazeId' && tvMazeId ? ( +
TvMazeId: {tvMazeId}
+ ) : null} + + {match.key === 'imdbId' && imdbId ? ( +
ImdbId: {imdbId}
+ ) : null} + + {match.key === 'tmdbId' && tmdbId ? ( +
TmdbId: {tmdbId}
+ ) : null} + + {tag ? ( +
+ +
+ ) : null} +
+
+ ); +} + +export default SeriesSearchResult; diff --git a/frontend/src/Components/Page/Header/fuse.worker.js b/frontend/src/Components/Page/Header/fuse.worker.ts similarity index 73% rename from frontend/src/Components/Page/Header/fuse.worker.js rename to frontend/src/Components/Page/Header/fuse.worker.ts index 154607038..2e2a8e4fd 100644 --- a/frontend/src/Components/Page/Header/fuse.worker.js +++ b/frontend/src/Components/Page/Header/fuse.worker.ts @@ -1,4 +1,7 @@ +// eslint-disable filenames/match-exported + import Fuse from 'fuse.js'; +import { SuggestedSeries } from './SeriesSearchInput'; const fuseOptions = { shouldSort: true, @@ -14,11 +17,11 @@ const fuseOptions = { 'tvMazeId', 'imdbId', 'tmdbId', - 'tags.label' - ] + 'tags.label', + ], }; -function getSuggestions(series, value) { +function getSuggestions(series: SuggestedSeries[], value: string) { const limit = 10; let suggestions = []; @@ -28,16 +31,14 @@ function getSuggestions(series, value) { if (s.firstCharacter === value.toLowerCase()) { suggestions.push({ item: series[i], - indices: [ - [0, 0] - ], + indices: [[0, 0]], matches: [ { value: s.title, - key: 'title' - } + key: 'title', + }, ], - refIndex: 0 + refIndex: 0, }); if (suggestions.length > limit) { break; @@ -52,21 +53,18 @@ function getSuggestions(series, value) { return suggestions; } -onmessage = function(e) { +onmessage = function (e) { if (!e) { return; } - const { - series, - value - } = e.data; + const { series, value } = e.data; const suggestions = getSuggestions(series, value); const results = { value, - suggestions + suggestions, }; self.postMessage(results); diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js deleted file mode 100644 index 8db101b26..000000000 --- a/frontend/src/Components/Page/LoadingPage.js +++ /dev/null @@ -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 ( -
- - - -
- ); -} - -export default LoadingPage; diff --git a/frontend/src/Components/Page/LoadingPage.tsx b/frontend/src/Components/Page/LoadingPage.tsx new file mode 100644 index 000000000..8b2cf13f9 --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.tsx @@ -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 ( +
+ + + +
+ ); +} + +export default LoadingPage; diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js deleted file mode 100644 index 1386865e8..000000000 --- a/frontend/src/Components/Page/Page.js +++ /dev/null @@ -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 ( - -
- - - - -
- - - {children} -
- - - - - - -
-
- ); - } -} - -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; diff --git a/frontend/src/Components/Page/Page.tsx b/frontend/src/Components/Page/Page.tsx new file mode 100644 index 000000000..f7132ec32 --- /dev/null +++ b/frontend/src/Components/Page/Page.tsx @@ -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 ( + + ); + } + + if (!isPopulated) { + return ; + } + + return ( + +
+ + + + +
+ + + {children} +
+ + + + + + +
+
+ ); +} + +export default Page; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js deleted file mode 100644 index 95416ea3c..000000000 --- a/frontend/src/Components/Page/PageConnector.js +++ /dev/null @@ -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 ( - - ); - } - - if (isPopulated) { - return ( - - ); - } - - return ( - - ); - } -} - -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) -); diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js deleted file mode 100644 index b1f09484e..000000000 --- a/frontend/src/Components/Page/PageContent.js +++ /dev/null @@ -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 ( - - -
- {children} -
-
-
- ); -} - -PageContent.propTypes = { - className: PropTypes.string, - title: PropTypes.string, - children: PropTypes.node.isRequired -}; - -PageContent.defaultProps = { - className: styles.content -}; - -export default PageContent; diff --git a/frontend/src/Components/Page/PageContent.tsx b/frontend/src/Components/Page/PageContent.tsx new file mode 100644 index 000000000..438aa1db7 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.tsx @@ -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 ( + + +
{children}
+
+
+ ); +} + +export default PageContent; diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.tsx similarity index 72% rename from frontend/src/Components/Page/PageContentError.js rename to frontend/src/Components/Page/PageContentError.tsx index c645a6771..fe675abe6 100644 --- a/frontend/src/Components/Page/PageContentError.js +++ b/frontend/src/Components/Page/PageContentError.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import ErrorBoundaryError, { + ErrorBoundaryErrorProps, +} from 'Components/Error/ErrorBoundaryError'; import translate from 'Utilities/String/translate'; import PageContentBody from './PageContentBody'; import styles from './PageContentError.css'; -function PageContentError(props) { +function PageContentError(props: ErrorBoundaryErrorProps) { return (
diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js deleted file mode 100644 index 1f6e2d21a..000000000 --- a/frontend/src/Components/Page/PageContentFooter.js +++ /dev/null @@ -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 ( -
- {children} -
- ); - } -} - -PageContentFooter.propTypes = { - className: PropTypes.string, - children: PropTypes.node.isRequired -}; - -PageContentFooter.defaultProps = { - className: styles.contentFooter -}; - -export default PageContentFooter; diff --git a/frontend/src/Components/Page/PageContentFooter.tsx b/frontend/src/Components/Page/PageContentFooter.tsx new file mode 100644 index 000000000..bc44aa7a6 --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.tsx @@ -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
{children}
; +} + +export default PageContentFooter; diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js deleted file mode 100644 index ece3c7353..000000000 --- a/frontend/src/Components/Page/PageJumpBar.js +++ /dev/null @@ -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 ( -
- -
- { - visibleItems.map((item) => { - return ( - - ); - }) - } -
-
-
- ); - } -} - -PageJumpBar.propTypes = { - items: PropTypes.object.isRequired, - minimumItems: PropTypes.number.isRequired, - onItemPress: PropTypes.func.isRequired -}; - -PageJumpBar.defaultProps = { - minimumItems: 5 -}; - -export default PageJumpBar; diff --git a/frontend/src/Components/Page/PageJumpBar.tsx b/frontend/src/Components/Page/PageJumpBar.tsx new file mode 100644 index 000000000..ff6a14984 --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.tsx @@ -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; + 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 ( +
+
+ {visibleItems.map((item) => { + return ( + + ); + })} +
+
+ ); +} + +export default PageJumpBar; diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js deleted file mode 100644 index daee9837a..000000000 --- a/frontend/src/Components/Page/PageJumpBarItem.js +++ /dev/null @@ -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 ( - - {this.props.label.toUpperCase()} - - ); - } -} - -PageJumpBarItem.propTypes = { - label: PropTypes.string.isRequired, - onItemPress: PropTypes.func.isRequired -}; - -export default PageJumpBarItem; diff --git a/frontend/src/Components/Page/PageJumpBarItem.tsx b/frontend/src/Components/Page/PageJumpBarItem.tsx new file mode 100644 index 000000000..76389066d --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.tsx @@ -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 ( + + {label.toUpperCase()} + + ); +} + +export default PageJumpBarItem; diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js deleted file mode 100644 index 2cef9eef1..000000000 --- a/frontend/src/Components/Page/PageSectionContent.js +++ /dev/null @@ -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 ( - - ); - } else if (!isFetching && !!error) { - return ( - {errorMessage} - ); - } else if (isPopulated && !error) { - return ( -
{children}
- ); - } - - 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; diff --git a/frontend/src/Components/Page/PageSectionContent.tsx b/frontend/src/Components/Page/PageSectionContent.tsx new file mode 100644 index 000000000..808de3e38 --- /dev/null +++ b/frontend/src/Components/Page/PageSectionContent.tsx @@ -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 ; + } + + if (!isFetching && !!error) { + return {errorMessage}; + } + + if (isPopulated && !error) { + return
{children}
; + } + + return null; +} + +export default PageSectionContent; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js deleted file mode 100644 index 0bb1f4e06..000000000 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ /dev/null @@ -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 ( -
- -
- { - 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 ( - - { - link.children && link.to === activeParent && - link.children.map((child) => { - return ( - - ); - }) - } - - ); - }) - } -
- - -
-
- ); - } -} - -PageSidebar.propTypes = { - location: locationShape.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isSidebarVisible: PropTypes.bool.isRequired, - onSidebarVisibleChange: PropTypes.func.isRequired -}; - -export default PageSidebar; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.tsx b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx new file mode 100644 index 000000000..7b3ef8292 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx @@ -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(null); + const touchStartY = useRef(); + 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 ( +
+ +
+ {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 ( + + {link.children && + link.to === activeParent && + link.children.map((child) => { + return ( + + ); + })} + + ); + })} +
+ + +
+
+ ); +} + +export default PageSidebar; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js deleted file mode 100644 index 754071c79..000000000 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js +++ /dev/null @@ -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 ( -
- - { - !!iconName && - - - - } - - - {typeof title === 'function' ? title() : title} - - - { - !!StatusComponent && - - - - } - - - { - children && - map(children, (child) => { - return React.cloneElement(child, { isChildItem: true }); - }) - } -
- ); - } -} - -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; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx new file mode 100644 index 000000000..f84edf13e --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx @@ -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 ( +
+ + {!!iconName && ( + + + + )} + + + {typeof title === 'function' ? title() : title} + + + {!!StatusComponent && ( + + + + )} + + + {children + ? Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + const childProps = { isChildItem: true }; + + return React.cloneElement(child, childProps); + }) + : null} +
+ ); +} + +export default PageSidebarItem; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js deleted file mode 100644 index 14ece5127..000000000 --- a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js +++ /dev/null @@ -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 ( - - ); -} - -PageSidebarStatus.propTypes = { - count: PropTypes.number, - errors: PropTypes.bool, - warnings: PropTypes.bool -}; - -export default PageSidebarStatus; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.tsx b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.tsx new file mode 100644 index 000000000..02e1d221c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.tsx @@ -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 ( + + ); +} + +export default PageSidebarStatus; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js deleted file mode 100644 index 728f1b0d9..000000000 --- a/frontend/src/Components/Page/Toolbar/PageToolbar.js +++ /dev/null @@ -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 ( -
- {children} -
- ); - } -} - -PageToolbar.propTypes = { - className: PropTypes.string, - children: PropTypes.node.isRequired -}; - -PageToolbar.defaultProps = { - className: styles.toolbar -}; - -export default PageToolbar; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.tsx b/frontend/src/Components/Page/Toolbar/PageToolbar.tsx new file mode 100644 index 000000000..c465a3a79 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.tsx @@ -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
{children}
; +} + +export default PageToolbar; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js deleted file mode 100644 index c93603aa9..000000000 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ /dev/null @@ -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 ( - - - -
-
- {label} -
-
- - ); -} - -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; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx new file mode 100644 index 000000000..1b87c09fa --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx @@ -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; +} + +function PageToolbarButton({ + label, + iconName, + spinningName = icons.SPINNER, + isDisabled = false, + isSpinning = false, + overflowComponent, + ...otherProps +}: PageToolbarButtonProps) { + return ( + + + +
+
{label}
+
+ + ); +} + +export default PageToolbarButton; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx index c97eb2a91..dad52ee19 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx @@ -1,18 +1,18 @@ -import { IconDefinition } from '@fortawesome/fontawesome-common-types'; -import React from 'react'; +import React, { SyntheticEvent } from 'react'; +import { IconName } from 'Components/Icon'; import MenuItem from 'Components/Menu/MenuItem'; import SpinnerIcon from 'Components/SpinnerIcon'; import styles from './PageToolbarOverflowMenuItem.css'; interface PageToolbarOverflowMenuItemProps { - iconName: IconDefinition; - spinningName?: IconDefinition; + iconName: IconName; + spinningName?: IconName; isDisabled?: boolean; isSpinning?: boolean; showIndicator?: boolean; label: string; text?: string; - onPress: () => void; + onPress?: (event: SyntheticEvent) => void; } function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) { diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js deleted file mode 100644 index 2d50bab8b..000000000 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ /dev/null @@ -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 ( - -
- { - isMeasured ? -
- { - buttons.map((button) => { - return button; - }) - } - - { - !!overflowItems.length && - - - - - { - overflowItems.map((item) => { - const { - label, - overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem - } = item; - - return ( - - ); - }) - } - - - } -
: - null - } -
-
- ); - } - -} - -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; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx new file mode 100644 index 000000000..e2aafb989 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.tsx @@ -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 | ReactElement) + | (ReactElement | ReactElement)[]; + alignContent?: Extract; + 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[] = []; + 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 ( +
+ {isMeasured ? ( +
+ {buttons.map((button) => { + return button; + })} + + {overflowItems.length ? ( + + + + + {overflowItems.map((item) => { + const { + label, + overflowComponent: + OverflowComponent = PageToolbarOverflowMenuItem, + } = item; + + return ; + })} + + + ) : null} +
+ ) : null} +
+ ); +} + +export default PageToolbarSection; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js deleted file mode 100644 index 754248f99..000000000 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, { Component } from 'react'; -import styles from './PageToolbarSeparator.css'; - -class PageToolbarSeparator extends Component { - - // - // Render - - render() { - return ( -
- ); - } - -} - -export default PageToolbarSeparator; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.tsx new file mode 100644 index 000000000..4cfd5862b --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import styles from './PageToolbarSeparator.css'; + +function PageToolbarSeparator() { + return
; +} + +export default PageToolbarSeparator; diff --git a/frontend/src/Components/Scroller/OverlayScroller.tsx b/frontend/src/Components/Scroller/OverlayScroller.tsx index b242642e8..3fd10a4e6 100644 --- a/frontend/src/Components/Scroller/OverlayScroller.tsx +++ b/frontend/src/Components/Scroller/OverlayScroller.tsx @@ -11,8 +11,8 @@ interface OverlayScrollerProps { trackClassName?: string; scrollTop?: number; scrollDirection: ScrollDirection; - autoHide: boolean; - autoScroll: boolean; + autoHide?: boolean; + autoScroll?: boolean; children?: React.ReactNode; onScroll?: (payload: OnScroll) => void; } diff --git a/frontend/src/Components/SpinnerIcon.tsx b/frontend/src/Components/SpinnerIcon.tsx index d9124d692..dfb7b4b5a 100644 --- a/frontend/src/Components/SpinnerIcon.tsx +++ b/frontend/src/Components/SpinnerIcon.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { icons } from 'Helpers/Props'; -import Icon, { IconProps } from './Icon'; +import Icon, { IconName, IconProps } from './Icon'; export interface SpinnerIconProps extends IconProps { - spinningName?: IconProps['name']; + spinningName?: IconName; isSpinning: Required; } diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.tsx similarity index 60% rename from frontend/src/Components/keyboardShortcuts.js rename to frontend/src/Components/keyboardShortcuts.tsx index 7ba54ac46..e7e37d338 100644 --- a/frontend/src/Components/keyboardShortcuts.js +++ b/frontend/src/Components/keyboardShortcuts.tsx @@ -1,53 +1,71 @@ -import Mousetrap from 'mousetrap'; -import React, { Component } from 'react'; -import getDisplayName from 'Helpers/getDisplayName'; +import Mousetrap, { MousetrapInstance } from 'mousetrap'; +import React, { Component, ComponentType } from 'react'; import translate from 'Utilities/String/translate'; -export const shortcuts = { +export interface Shortcut { + key: string; + name: string; +} + +interface BindingOptions { + isGlobal?: boolean; +} + +interface KeyboardShortcutsProps { + bindShortcut: ( + key: string, + callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void, + options?: BindingOptions + ) => void; + unbindShortcut: (key: string) => void; +} + +export const shortcuts: Record = { OPEN_KEYBOARD_SHORTCUTS_MODAL: { key: '?', get name() { return translate('KeyboardShortcutsOpenModal'); - } + }, }, CLOSE_MODAL: { key: 'Esc', get name() { return translate('KeyboardShortcutsCloseModal'); - } + }, }, ACCEPT_CONFIRM_MODAL: { key: 'Enter', get name() { return translate('KeyboardShortcutsConfirmModal'); - } + }, }, SERIES_SEARCH_INPUT: { key: 's', get name() { return translate('KeyboardShortcutsFocusSearchBox'); - } + }, }, SAVE_SETTINGS: { key: 'mod+s', get name() { return translate('KeyboardShortcutsSaveSettings'); - } - } + }, + }, }; -function keyboardShortcuts(WrappedComponent) { +function keyboardShortcuts( + WrappedComponent: ComponentType +) { class KeyboardShortcuts extends Component { - // // Lifecycle - constructor(props, context) { - super(props, context); + constructor(props: never) { + super(props); this._mousetrapBindings = {}; this._mousetrap = new Mousetrap(); this._mousetrap.stopCallback = this.stopCallback; @@ -58,15 +76,22 @@ function keyboardShortcuts(WrappedComponent) { this._mousetrap = null; } + _mousetrap: MousetrapInstance | null; + _mousetrapBindings: Record; + // // Control - bindShortcut = (key, callback, options = {}) => { - this._mousetrap.bind(key, callback); + bindShortcut = ( + key: string, + callback: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void, + options: BindingOptions = {} + ) => { + this._mousetrap?.bind(key, callback); this._mousetrapBindings[key] = options; }; - unbindShortcut = (key) => { + unbindShortcut = (key: string) => { if (this._mousetrap != null) { delete this._mousetrapBindings[key]; this._mousetrap.unbind(key); @@ -81,13 +106,17 @@ function keyboardShortcuts(WrappedComponent) { } keys.forEach((binding) => { - this._mousetrap.unbind(binding); + this._mousetrap?.unbind(binding); }); this._mousetrapBindings = {}; }; - stopCallback = (event, element, combo) => { + stopCallback = ( + _e: Mousetrap.ExtendedKeyboardEvent, + element: Element, + combo: string + ) => { const binding = this._mousetrapBindings[combo]; if (!binding || binding.isGlobal) { @@ -98,7 +127,7 @@ function keyboardShortcuts(WrappedComponent) { element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA' || - (element.contentEditable && element.contentEditable === 'true') + ('contentEditable' in element && element.contentEditable === 'true') ); }; @@ -116,9 +145,6 @@ function keyboardShortcuts(WrappedComponent) { } } - KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`; - KeyboardShortcuts.WrappedComponent = WrappedComponent; - return KeyboardShortcuts; } diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts new file mode 100644 index 000000000..1e65b7025 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -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; diff --git a/frontend/src/Helpers/Hooks/useKeyboardShortcuts.ts b/frontend/src/Helpers/Hooks/useKeyboardShortcuts.ts new file mode 100644 index 000000000..b9baa5ce1 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useKeyboardShortcuts.ts @@ -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>({}); + const mouseTrap = useRef(); + + 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; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx index 6024e7967..df4ad1d96 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.tsx @@ -1,6 +1,6 @@ -import { IconDefinition } from '@fortawesome/free-regular-svg-icons'; import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; +import { IconName } from 'Components/Icon'; import { icons } from 'Helpers/Props'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import dimensions from 'Styles/Variables/dimensions'; @@ -21,7 +21,7 @@ interface RowProps { interface RowInfoProps { title: string; - iconName: IconDefinition; + iconName: IconName; label: string; } diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.tsx index b23b915c8..836f012c9 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import Icon, { IconProps } from 'Components/Icon'; +import Icon, { IconName } from 'Components/Icon'; import styles from './SeriesIndexOverviewInfoRow.css'; interface SeriesIndexOverviewInfoRowProps { title?: string; - iconName: IconProps['name']; + iconName: IconName; label: string | null; } diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx index 4fd851026..5ce83a2ed 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx @@ -1,17 +1,18 @@ import React, { useCallback } from 'react'; import { useSelect } from 'App/SelectContext'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarButton, { + PageToolbarButtonProps, +} from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -interface SeriesIndexSelectAllButtonProps { - label: string; +interface SeriesIndexSelectAllButtonProps + extends Omit { isSelectMode: boolean; - overflowComponent: React.FunctionComponent; } function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) { - const { isSelectMode } = props; + const { isSelectMode, overflowComponent } = props; const [selectState, selectDispatch] = useSelect(); const { allSelected, allUnselected } = selectState; @@ -33,6 +34,7 @@ function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) { ) : null; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx index 2b5e1dd85..96c591c1d 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx @@ -1,18 +1,16 @@ -import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import React, { useCallback } from 'react'; import { useSelect } from 'App/SelectContext'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarButton, { + PageToolbarButtonProps, +} from 'Components/Page/Toolbar/PageToolbarButton'; -interface SeriesIndexSelectModeButtonProps { - label: string; - iconName: IconDefinition; +interface SeriesIndexSelectModeButtonProps extends PageToolbarButtonProps { isSelectMode: boolean; - overflowComponent: React.FunctionComponent; onPress: () => void; } function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) { - const { label, iconName, isSelectMode, onPress } = props; + const { label, iconName, isSelectMode, overflowComponent, onPress } = props; const [, selectDispatch] = useSelect(); const onPressWrapper = useCallback(() => { @@ -29,6 +27,7 @@ function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) { ); diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx index 06da15dde..bf8f6bdf1 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx @@ -1,11 +1,11 @@ -import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import React, { useCallback } from 'react'; import { useSelect } from 'App/SelectContext'; +import { IconName } from 'Components/Icon'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; interface SeriesIndexSelectModeMenuItemProps { label: string; - iconName: IconDefinition; + iconName: IconName; isSelectMode: boolean; onPress: () => void; } diff --git a/frontend/src/Series/Index/SeriesIndex.tsx b/frontend/src/Series/Index/SeriesIndex.tsx index 1f9a3e549..bb7f4f5d0 100644 --- a/frontend/src/Series/Index/SeriesIndex.tsx +++ b/frontend/src/Series/Index/SeriesIndex.tsx @@ -14,7 +14,7 @@ import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; -import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageJumpBar, { PageJumpBarItems } from 'Components/Page/PageJumpBar'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; @@ -174,10 +174,11 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { [setJumpToCharacter] ); - const jumpBarItems = useMemo(() => { + const jumpBarItems: PageJumpBarItems = useMemo(() => { // Reset if not sorting by sortTitle if (sortKey !== 'sortTitle') { return { + characters: {}, order: [], }; } diff --git a/frontend/src/Store/Selectors/createTagsSelector.ts b/frontend/src/Store/Selectors/createTagsSelector.ts index f653ff6e3..2ec629ceb 100644 --- a/frontend/src/Store/Selectors/createTagsSelector.ts +++ b/frontend/src/Store/Selectors/createTagsSelector.ts @@ -1,7 +1,8 @@ import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; +import { Tag } from 'App/State/TagsAppState'; -function createTagsSelector() { +function createTagsSelector(): (state: AppState) => Tag[] { return createSelector( (state: AppState) => state.tags.items, (tags) => { diff --git a/frontend/src/Utilities/Object/getErrorMessage.ts b/frontend/src/Utilities/Object/getErrorMessage.ts index 72474b853..b250b1258 100644 --- a/frontend/src/Utilities/Object/getErrorMessage.ts +++ b/frontend/src/Utilities/Object/getErrorMessage.ts @@ -1,6 +1,6 @@ import { Error } from 'App/State/AppSectionState'; -function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) { +function getErrorMessage(xhr: Error, fallbackErrorMessage = '') { if (!xhr || !xhr.responseJSON) { return fallbackErrorMessage; } diff --git a/frontend/typings/worker-loader.d.ts b/frontend/typings/worker-loader.d.ts new file mode 100644 index 000000000..3dac70cb5 --- /dev/null +++ b/frontend/typings/worker-loader.d.ts @@ -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; +} diff --git a/package.json b/package.json index 600ac5df3..0c244c0cb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "copy-to-clipboard": "3.3.3", "element-class": "0.2.2", "filesize": "10.1.6", - "fuse.js": "6.6.2", + "fuse.js": "7.0.0", "history": "4.10.1", "jdu": "1.0.0", "jquery": "3.7.1", @@ -92,6 +92,7 @@ "@babel/preset-react": "7.26.3", "@babel/preset-typescript": "7.26.0", "@types/lodash": "4.14.195", + "@types/mousetrap": "1.6.15", "@types/qs": "6.9.16", "@types/react-autosuggest": "10.1.11", "@types/react-document-title": "2.0.10", diff --git a/yarn.lock b/yarn.lock index 15f6e4931..16d3048b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1350,6 +1350,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== +"@types/mousetrap@1.6.15": + version "1.6.15" + resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86" + integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw== + "@types/node@*": version "22.7.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b" @@ -3547,10 +3552,10 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== -fuse.js@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111" - integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA== +fuse.js@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2" + integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q== gensync@^1.0.0-beta.2: version "1.0.0-beta.2"