-
- {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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
-
-
-
- );
- }
-}
-
-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 (
+
+
+
+ );
+}
+
+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 (
-
-
-
-
-
- );
-}
-
-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 (
+
+
+
+
+
+ );
+}
+
+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 &&
-
- }
- :
- 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 ? (
+
+ ) : 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"