-
- {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 d80877153f..0000000000
--- 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/MovieSearchInput.js b/frontend/src/Components/Page/Header/MovieSearchInput.js
deleted file mode 100644
index 33fee131a6..0000000000
--- a/frontend/src/Components/Page/Header/MovieSearchInput.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 MovieSearchResult from './MovieSearchResult';
-import styles from './MovieSearchInput.css';
-
-const ADD_NEW_TYPE = 'addNew';
-
-class MovieSearchInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._autosuggest = null;
- this._worker = null;
-
- this.state = {
- value: '',
- suggestions: []
- };
- }
-
- componentDidMount() {
- this.props.bindShortcut(shortcuts.MOVIE_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 (
-
- );
- }
-
- goToMovie(item) {
- this.setState({ value: '' });
- this.props.onGoToMovie(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.onGoToAddNewMovie(value);
- this._autosuggest.input.blur();
- this.reset();
-
- return;
- }
-
- // If an suggestion is not selected go to the first movie,
- // otherwise go to the selected movie.
-
- if (highlightedSuggestionIndex == null) {
- this.goToMovie(suggestions[0]);
- } else {
- this.goToMovie(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,
- movies: this.props.movies
- };
-
- 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,
- movies: this.props.movies
- };
-
- this.getWorker().postMessage(payload);
- }
- };
-
- onSuggestionsClearRequested = () => {
- this.setState({
- suggestions: [],
- loading: false
- });
- };
-
- onSuggestionSelected = (event, { suggestion }) => {
- if (suggestion.type === ADD_NEW_TYPE) {
- this.props.onGoToAddNewMovie(this.state.value);
- } else {
- this.goToMovie(suggestion);
- }
- };
-
- //
- // Render
-
- render() {
- const {
- value,
- loading,
- suggestions
- } = this.state;
-
- const suggestionGroups = [];
-
- if (suggestions.length || loading) {
- suggestionGroups.push({
- title: translate('ExistingMovies'),
- loading,
- suggestions
- });
- }
-
- suggestionGroups.push({
- title: translate('AddNewMovie'),
- suggestions: [
- {
- type: ADD_NEW_TYPE,
- title: value
- }
- ]
- });
-
- const inputProps = {
- ref: this.setInputRef,
- className: styles.input,
- name: 'movieSearch',
- 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.movieContainer,
- suggestionsList: styles.list,
- suggestion: styles.listItem,
- suggestionHighlighted: styles.highlighted
- };
-
- return (
-
- );
- }
-}
-
-MovieSearchInput.propTypes = {
- movies: PropTypes.arrayOf(PropTypes.object).isRequired,
- onGoToMovie: PropTypes.func.isRequired,
- onGoToAddNewMovie: PropTypes.func.isRequired,
- bindShortcut: PropTypes.func.isRequired
-};
-
-export default keyboardShortcuts(MovieSearchInput);
diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.tsx b/frontend/src/Components/Page/Header/MovieSearchInput.tsx
new file mode 100644
index 0000000000..3d82ea8199
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchInput.tsx
@@ -0,0 +1,457 @@
+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 Movie from 'Movie/Movie';
+import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
+import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import translate from 'Utilities/String/translate';
+import MovieSearchResult from './MovieSearchResult';
+import styles from './MovieSearchInput.css';
+
+const ADD_NEW_TYPE = 'addNew';
+
+interface Match {
+ key: string;
+ refIndex: number;
+}
+
+interface AddNewMovieSuggestion {
+ type: 'addNew';
+ title: string;
+}
+
+export interface SuggestedMovie
+ extends Pick<
+ Movie,
+ | 'title'
+ | 'year'
+ | 'titleSlug'
+ | 'sortTitle'
+ | 'images'
+ | 'alternateTitles'
+ | 'tmdbId'
+ | 'imdbId'
+ > {
+ firstCharacter: string;
+ tags: Tag[];
+}
+
+interface MovieSuggestion {
+ title: string;
+ indices: number[];
+ item: SuggestedMovie;
+ matches: Match[];
+ refIndex: number;
+}
+
+interface Section {
+ title: string;
+ loading?: boolean;
+ suggestions: MovieSuggestion[] | AddNewMovieSuggestion[];
+}
+
+function createUnoptimizedSelector() {
+ return createSelector(
+ createAllMoviesSelector(),
+ createTagsSelector(),
+ (allMovies, allTags) => {
+ return allMovies.map((movie): SuggestedMovie => {
+ const {
+ title,
+ year,
+ titleSlug,
+ sortTitle,
+ images,
+ alternateTitles = [],
+ tmdbId,
+ imdbId,
+ tags = [],
+ } = movie;
+
+ return {
+ title,
+ year,
+ titleSlug,
+ sortTitle,
+ images,
+ alternateTitles,
+ tmdbId,
+ imdbId,
+ 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 createMoviesSelector() {
+ return createDeepEqualSelector(
+ createUnoptimizedSelector(),
+ (movies) => movies
+ );
+}
+
+function MovieSearchInput() {
+ const movies = useSelector(createMoviesSelector());
+ 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('ExistingMovies'),
+ loading: isLoading.current,
+ suggestions,
+ });
+ }
+
+ result.push({
+ title: translate('AddNewMovie'),
+ suggestions: [
+ {
+ type: ADD_NEW_TYPE,
+ title: value,
+ },
+ ],
+ });
+
+ return result;
+ }, [suggestions, value]);
+
+ const handleSuggestionsReceived = useCallback(
+ (message: { data: { value: string; suggestions: MovieSuggestion[] } }) => {
+ 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,
+ movies,
+ };
+
+ worker.current?.postMessage(payload);
+ }
+ },
+ [movies]
+ );
+
+ const requestSuggestions = useDebouncedCallback((value: string) => {
+ if (!isLoading.current) {
+ return;
+ }
+
+ requestValue.current = value;
+ setRequestLoading(true);
+
+ if (!requestLoading) {
+ const payload = {
+ value,
+ movies,
+ };
+
+ 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: AddNewMovieSuggestion | MovieSuggestion,
+ { 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.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
+ )
+ );
+
+ inputRef.current?.blur();
+ reset();
+
+ return;
+ }
+
+ // If a suggestion is not selected go to the first movie,
+ // otherwise go to the selected movie.
+
+ const selectedSuggestion =
+ highlightedSuggestionIndex == null
+ ? suggestions[0]
+ : suggestions[highlightedSuggestionIndex];
+
+ dispatch(
+ push(
+ `${window.Radarr.urlBase}/movie/${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: MovieSuggestion | AddNewMovieSuggestion }
+ ) => {
+ if ('type' in suggestion) {
+ dispatch(
+ push(
+ `${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
+ )
+ );
+ } else {
+ setValue('');
+ dispatch(
+ push(`${window.Radarr.urlBase}/movie/${suggestion.item.titleSlug}`)
+ );
+ }
+ },
+ [value, dispatch]
+ );
+
+ const inputProps = {
+ ref: inputRef,
+ className: styles.input,
+ name: 'movieSearch',
+ value,
+ placeholder: translate('Search'),
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: handleChange,
+ onKeyDown: handleKeyDown,
+ onBlur: handleBlur,
+ };
+
+ const theme = {
+ container: styles.container,
+ containerOpen: styles.containerOpen,
+ suggestionsContainer: styles.movieContainer,
+ 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('focusMovieSearchInput', focusInput);
+
+ return () => {
+ unbindShortcut('focusMovieSearchInput');
+ };
+ }, [bindShortcut, unbindShortcut, focusInput]);
+
+ return (
+
+ );
+}
+
+export default MovieSearchInput;
diff --git a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
deleted file mode 100644
index 84390140ef..0000000000
--- a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { push } from 'connected-react-router';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
-import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
-import createTagsSelector from 'Store/Selectors/createTagsSelector';
-import MovieSearchInput from './MovieSearchInput';
-
-function createCleanMovieSelector() {
- return createSelector(
- createAllMoviesSelector(),
- createTagsSelector(),
- (allMovies, allTags) => {
- return allMovies.map((movie) => {
- const {
- title,
- titleSlug,
- sortTitle,
- year,
- images,
- alternateTitles = [],
- tmdbId,
- imdbId,
- tags = []
- } = movie;
-
- return {
- title,
- titleSlug,
- sortTitle,
- year,
- images,
- alternateTitles,
- tmdbId,
- imdbId,
- 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(
- createCleanMovieSelector(),
- (movies) => {
- return {
- movies
- };
- }
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onGoToMovie(titleSlug) {
- dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
- },
-
- onGoToAddNewMovie(query) {
- dispatch(push(`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
- }
- };
-}
-
-export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchInput);
diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.js b/frontend/src/Components/Page/Header/MovieSearchResult.js
deleted file mode 100644
index ab15130d00..0000000000
--- a/frontend/src/Components/Page/Header/MovieSearchResult.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Label from 'Components/Label';
-import { kinds } from 'Helpers/Props';
-import MoviePoster from 'Movie/MoviePoster';
-import styles from './MovieSearchResult.css';
-
-function MovieSearchResult(props) {
- const {
- match,
- title,
- year,
- images,
- alternateTitles,
- tmdbId,
- imdbId,
- 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} { year > 0 ? `(${year})` : ''}
-
-
- {
- alternateTitle ?
-
- {alternateTitle.title}
-
:
- null
- }
-
- {
- match.key === 'tmdbId' && tmdbId ?
-
- TmdbId: {tmdbId}
-
:
- null
- }
-
- {
- match.key === 'imdbId' && imdbId ?
-
- ImdbId: {imdbId}
-
:
- null
- }
-
- {
- tag ?
-
-
-
:
- null
- }
-
-
- );
-}
-
-MovieSearchResult.propTypes = {
- title: PropTypes.string.isRequired,
- year: PropTypes.number.isRequired,
- images: PropTypes.arrayOf(PropTypes.object).isRequired,
- alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
- tmdbId: PropTypes.number,
- imdbId: PropTypes.string,
- tags: PropTypes.arrayOf(PropTypes.object).isRequired,
- match: PropTypes.object.isRequired
-};
-
-export default MovieSearchResult;
diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.tsx b/frontend/src/Components/Page/Header/MovieSearchResult.tsx
new file mode 100644
index 0000000000..71e8546545
--- /dev/null
+++ b/frontend/src/Components/Page/Header/MovieSearchResult.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { Tag } from 'App/State/TagsAppState';
+import Label from 'Components/Label';
+import { kinds } from 'Helpers/Props';
+import MoviePoster from 'Movie/MoviePoster';
+import { SuggestedMovie } from './MovieSearchInput';
+import styles from './MovieSearchResult.css';
+
+interface Match {
+ key: string;
+ refIndex: number;
+}
+
+interface MovieSearchResultProps extends SuggestedMovie {
+ match: Match;
+}
+
+function MovieSearchResult(props: MovieSearchResultProps) {
+ const { match, title, year, images, alternateTitles, tmdbId, imdbId, 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} {year > 0 ? `(${year})` : ''}
+
+
+ {alternateTitle ? (
+
{alternateTitle.title}
+ ) : null}
+
+ {match.key === 'tmdbId' && tmdbId ? (
+
TmdbId: {tmdbId}
+ ) : null}
+
+ {match.key === 'imdbId' && imdbId ? (
+
ImdbId: {imdbId}
+ ) : null}
+
+ {tag ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export default MovieSearchResult;
diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css
index 8ac10122e9..d89187ea0c 100644
--- a/frontend/src/Components/Page/Header/PageHeader.css
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -15,6 +15,10 @@
padding-left: 20px;
}
+.logoLink {
+ line-height: 0;
+}
+
.logoFull,
.logo {
vertical-align: middle;
diff --git a/frontend/src/Components/Page/Header/PageHeader.css.d.ts b/frontend/src/Components/Page/Header/PageHeader.css.d.ts
index 459edffdf5..6ea7b0e912 100644
--- a/frontend/src/Components/Page/Header/PageHeader.css.d.ts
+++ b/frontend/src/Components/Page/Header/PageHeader.css.d.ts
@@ -6,6 +6,7 @@ interface CssExports {
'logo': string;
'logoContainer': string;
'logoFull': string;
+ 'logoLink': string;
'right': string;
'sidebarToggleContainer': string;
'translate': string;
diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js
deleted file mode 100644
index 8480412fa1..0000000000
--- a/frontend/src/Components/Page/Header/PageHeader.js
+++ /dev/null
@@ -1,116 +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 MovieSearchInputConnector from './MovieSearchInputConnector';
-import PageHeaderActionsMenu from './PageHeaderActionsMenu';
-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,
- isSmallScreen
- } = this.props;
-
- return (
-
-
-
-

-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-PageHeader.propTypes = {
- onSidebarToggle: PropTypes.func.isRequired,
- isSmallScreen: PropTypes.bool.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 0000000000..7d6259d5bf
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.tsx
@@ -0,0 +1,109 @@
+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 MovieSearchInput from './MovieSearchInput';
+import PageHeaderActionsMenu from './PageHeaderActionsMenu';
+import styles from './PageHeader.css';
+
+interface PageHeaderProps {
+ isSmallScreen: boolean;
+}
+
+function PageHeader({ isSmallScreen }: PageHeaderProps) {
+ 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/fuse.worker.js b/frontend/src/Components/Page/Header/fuse.worker.ts
similarity index 61%
rename from frontend/src/Components/Page/Header/fuse.worker.js
rename to frontend/src/Components/Page/Header/fuse.worker.ts
index 04e84185f2..065659ca07 100644
--- a/frontend/src/Components/Page/Header/fuse.worker.js
+++ b/frontend/src/Components/Page/Header/fuse.worker.ts
@@ -1,4 +1,5 @@
import Fuse from 'fuse.js';
+import { SuggestedMovie } from './MovieSearchInput';
const fuseOptions = {
shouldSort: true,
@@ -6,35 +7,27 @@ const fuseOptions = {
ignoreLocation: true,
threshold: 0.3,
minMatchCharLength: 1,
- keys: [
- 'title',
- 'alternateTitles.title',
- 'tmdbId',
- 'imdbId',
- 'tags.label'
- ]
+ keys: ['title', 'alternateTitles.title', 'tmdbId', 'imdbId', 'tags.label'],
};
-function getSuggestions(movies, value) {
+function getSuggestions(movies: SuggestedMovie[], value: string) {
const limit = 10;
let suggestions = [];
if (value.length === 1) {
for (let i = 0; i < movies.length; i++) {
- const s = movies[i];
- if (s.firstCharacter === value.toLowerCase()) {
+ const m = movies[i];
+ if (m.firstCharacter === value.toLowerCase()) {
suggestions.push({
item: movies[i],
- indices: [
- [0, 0]
- ],
+ indices: [[0, 0]],
matches: [
{
- value: s.title,
- key: 'title'
- }
+ value: m.title,
+ key: 'title',
+ },
],
- refIndex: 0
+ refIndex: 0,
});
if (suggestions.length > limit) {
break;
@@ -49,21 +42,18 @@ function getSuggestions(movies, value) {
return suggestions;
}
-onmessage = function(e) {
+onmessage = function (e) {
if (!e) {
return;
}
- const {
- movies,
- value
- } = e.data;
+ const { movies, value } = e.data;
const suggestions = getSuggestions(movies, 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 1c330c7bf3..0000000000
--- 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 radarrLogo = 'data:image/png;base64,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';
-
-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 0000000000..67994b8587
--- /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 radarrLogo =
+ 'data:image/png;base64,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';
+
+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 c2e3688273..0000000000
--- a/frontend/src/Components/Page/Page.js
+++ /dev/null
@@ -1,143 +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 0000000000..d23b74cef6
--- /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 6643e9c87e..0000000000
--- a/frontend/src/Components/Page/PageConnector.js
+++ /dev/null
@@ -1,315 +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 { fetchMovies } from 'Store/Actions/movieActions';
-import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions';
-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 = 'radarrTest';
-
- 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.movies.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.indexerFlags.isPopulated,
- (state) => state.settings.importLists.isPopulated,
- (state) => state.system.status.isPopulated,
- (state) => state.movieCollections.isPopulated,
- (state) => state.app.translations.isPopulated,
- (
- moviesIsPopulated,
- customFiltersIsPopulated,
- tagsIsPopulated,
- uiSettingsIsPopulated,
- qualityProfilesIsPopulated,
- languagesIsPopulated,
- indexerFlagsIsPopulated,
- importListsIsPopulated,
- systemStatusIsPopulated,
- movieCollectionsIsPopulated,
- translationsIsPopulated
- ) => {
- return (
- moviesIsPopulated &&
- customFiltersIsPopulated &&
- tagsIsPopulated &&
- uiSettingsIsPopulated &&
- qualityProfilesIsPopulated &&
- languagesIsPopulated &&
- indexerFlagsIsPopulated &&
- importListsIsPopulated &&
- systemStatusIsPopulated &&
- movieCollectionsIsPopulated &&
- translationsIsPopulated
- );
- }
-);
-
-const selectErrors = createSelector(
- (state) => state.movies.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.indexerFlags.error,
- (state) => state.settings.importLists.error,
- (state) => state.system.status.error,
- (state) => state.movieCollections.error,
- (state) => state.app.translations.error,
- (
- moviesError,
- customFiltersError,
- tagsError,
- uiSettingsError,
- qualityProfilesError,
- languagesError,
- indexerFlagsError,
- importListsError,
- systemStatusError,
- movieCollectionsError,
- translationsError
- ) => {
- const hasError = !!(
- moviesError ||
- customFiltersError ||
- tagsError ||
- uiSettingsError ||
- qualityProfilesError ||
- languagesError ||
- indexerFlagsError ||
- importListsError ||
- systemStatusError ||
- movieCollectionsError ||
- translationsError
- );
-
- return {
- hasError,
- customFiltersError,
- tagsError,
- uiSettingsError,
- qualityProfilesError,
- languagesError,
- indexerFlagsError,
- importListsError,
- systemStatusError,
- movieCollectionsError,
- 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 {
- dispatchFetchMovies() {
- dispatch(fetchMovies());
- },
- dispatchFetchMovieCollections() {
- dispatch(fetchMovieCollections());
- },
- dispatchFetchCustomFilters() {
- dispatch(fetchCustomFilters());
- },
- dispatchFetchTags() {
- dispatch(fetchTags());
- },
- dispatchFetchQualityProfiles() {
- dispatch(fetchQualityProfiles());
- },
- dispatchFetchLanguages() {
- dispatch(fetchLanguages());
- },
- dispatchFetchIndexerFlags() {
- dispatch(fetchIndexerFlags());
- },
- dispatchFetchImportLists() {
- dispatch(fetchImportLists());
- },
- 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.dispatchFetchMovies();
- this.props.dispatchFetchMovieCollections();
- this.props.dispatchFetchCustomFilters();
- this.props.dispatchFetchTags();
- this.props.dispatchFetchQualityProfiles();
- this.props.dispatchFetchLanguages();
- this.props.dispatchFetchIndexerFlags();
- this.props.dispatchFetchImportLists();
- this.props.dispatchFetchUISettings();
- this.props.dispatchFetchStatus();
- this.props.dispatchFetchTranslations();
- }
- }
-
- //
- // Listeners
-
- onSidebarToggle = () => {
- this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
- };
-
- //
- // Render
-
- render() {
- const {
- isPopulated,
- hasError,
- dispatchFetchMovies,
- dispatchFetchMovieCollections,
- dispatchFetchTags,
- dispatchFetchQualityProfiles,
- dispatchFetchLanguages,
- dispatchFetchIndexerFlags,
- dispatchFetchImportLists,
- 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,
- dispatchFetchMovies: PropTypes.func.isRequired,
- dispatchFetchMovieCollections: PropTypes.func.isRequired,
- dispatchFetchCustomFilters: PropTypes.func.isRequired,
- dispatchFetchTags: PropTypes.func.isRequired,
- dispatchFetchQualityProfiles: PropTypes.func.isRequired,
- dispatchFetchLanguages: PropTypes.func.isRequired,
- dispatchFetchIndexerFlags: PropTypes.func.isRequired,
- dispatchFetchImportLists: 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 b07e29cb88..0000000000
--- 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 0000000000..694bbb0381
--- /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 c645a6771d..fe675abe6e 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 1f6e2d21ae..0000000000
--- 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 0000000000..bc44aa7a61
--- /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 ece3c7353b..0000000000
--- 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 0000000000..ff6a149846
--- /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 daee9837a9..0000000000
--- 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 0000000000..76389066d9
--- /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 2cef9eef17..0000000000
--- 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 0000000000..808de3e38e
--- /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 18419ae616..0000000000
--- a/frontend/src/Components/Page/Sidebar/PageSidebar.js
+++ /dev/null
@@ -1,534 +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.MOVIE_CONTINUING,
- title: () => translate('Movies'),
- to: '/',
- alias: '/movies',
- children: [
- {
- title: () => translate('AddNew'),
- to: '/add/new'
- },
- {
- title: () => translate('ImportLibrary'),
- to: '/add/import'
- },
- {
- title: () => translate('Collections'),
- to: '/collections'
- },
- {
- title: () => translate('Discover'),
- to: '/add/discover'
- }
- ]
- },
-
- {
- 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('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.Radarr.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 0000000000..6dcc1ce216
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx
@@ -0,0 +1,524 @@
+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.MOVIE_CONTINUING,
+ title: () => translate('Movies'),
+ to: '/',
+ alias: '/movies',
+ children: [
+ {
+ title: () => translate('AddNew'),
+ to: '/add/new',
+ },
+ {
+ title: () => translate('ImportLibrary'),
+ to: '/add/import',
+ },
+ {
+ title: () => translate('Collections'),
+ to: '/collections',
+ },
+ {
+ title: () => translate('Discover'),
+ to: '/add/discover',
+ },
+ ],
+ },
+
+ {
+ 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('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.Radarr.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 8d0e4e7904..0000000000
--- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
+++ /dev/null
@@ -1,104 +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 0000000000..37d9bafa0b
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx
@@ -0,0 +1,81 @@
+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 14ece51271..0000000000
--- 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 0000000000..02e1d221c7
--- /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 728f1b0d9c..0000000000
--- 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 0000000000..c465a3a797
--- /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/PageToolbarOverflowMenuItem.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx
index c97eb2a915..dad52ee191 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 2d50bab8b8..0000000000
--- 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 0000000000..e2aafb989c
--- /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 754248f99e..0000000000
--- 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 0000000000..4cfd5862b1
--- /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 b242642e87..3fd10a4e6a 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 d9124d692b..dfb7b4b5a4 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 65%
rename from frontend/src/Components/keyboardShortcuts.js
rename to frontend/src/Components/keyboardShortcuts.tsx
index 680ccede90..6528fa58c2 100644
--- a/frontend/src/Components/keyboardShortcuts.js
+++ b/frontend/src/Components/keyboardShortcuts.tsx
@@ -1,81 +1,99 @@
-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');
- }
+ },
},
MOVIE_SEARCH_INPUT: {
key: 's',
get name() {
return translate('KeyboardShortcutsFocusSearchBox');
- }
+ },
},
SAVE_SETTINGS: {
key: 'mod+s',
get name() {
return translate('KeyboardShortcutsSaveSettings');
- }
+ },
},
SCROLL_TOP: {
key: 'mod+home',
get name() {
return translate('KeyboardShortcutsMovieIndexScrollTop');
- }
+ },
},
SCROLL_BOTTOM: {
key: 'mod+end',
get name() {
return translate('KeyboardShortcutsMovieIndexScrollBottom');
- }
+ },
},
DETAILS_NEXT: {
key: '→',
get name() {
return translate('KeyboardShortcutsMovieDetailsNextMovie');
- }
+ },
},
DETAILS_PREVIOUS: {
key: '←',
get name() {
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
- }
- }
+ },
+ },
};
-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;
@@ -86,15 +104,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);
@@ -109,13 +134,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) {
@@ -126,7 +155,7 @@ function keyboardShortcuts(WrappedComponent) {
element.tagName === 'INPUT' ||
element.tagName === 'SELECT' ||
element.tagName === 'TEXTAREA' ||
- (element.contentEditable && element.contentEditable === 'true')
+ ('contentEditable' in element && element.contentEditable === 'true')
);
};
@@ -144,9 +173,6 @@ function keyboardShortcuts(WrappedComponent) {
}
}
- KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
- KeyboardShortcuts.WrappedComponent = WrappedComponent;
-
return KeyboardShortcuts;
}
diff --git a/frontend/src/DiscoverMovie/Overview/DiscoverMovieOverviewInfoRow.tsx b/frontend/src/DiscoverMovie/Overview/DiscoverMovieOverviewInfoRow.tsx
index 7c1f70b04c..fd2d6b3936 100644
--- a/frontend/src/DiscoverMovie/Overview/DiscoverMovieOverviewInfoRow.tsx
+++ b/frontend/src/DiscoverMovie/Overview/DiscoverMovieOverviewInfoRow.tsx
@@ -1,10 +1,10 @@
import React from 'react';
-import Icon, { IconProps } from 'Components/Icon';
+import Icon, { IconName } from 'Components/Icon';
import styles from './DiscoverMovieOverviewInfoRow.css';
interface DiscoverMovieOverviewInfoRowProps {
title?: string;
- iconName: IconProps['name'];
+ iconName: IconName;
label: string | null;
}
diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts
new file mode 100644
index 0000000000..b84b90b395
--- /dev/null
+++ b/frontend/src/Helpers/Hooks/useAppPage.ts
@@ -0,0 +1,130 @@
+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 { fetchMovies } from 'Store/Actions/movieActions';
+import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions';
+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.movies.error,
+ (state: AppState) => state.movieCollections.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,
+ (
+ moviesError,
+ movieCollectionsError,
+ customFiltersError,
+ tagsError,
+ uiSettingsError,
+ qualityProfilesError,
+ languagesError,
+ importListsError,
+ indexerFlagsError,
+ systemStatusError,
+ translationsError
+ ) => {
+ const hasError = !!(
+ moviesError ||
+ movieCollectionsError ||
+ customFiltersError ||
+ tagsError ||
+ uiSettingsError ||
+ qualityProfilesError ||
+ languagesError ||
+ importListsError ||
+ indexerFlagsError ||
+ systemStatusError ||
+ translationsError
+ );
+
+ return {
+ hasError,
+ errors: {
+ moviesError,
+ movieCollectionsError,
+ customFiltersError,
+ tagsError,
+ uiSettingsError,
+ qualityProfilesError,
+ languagesError,
+ importListsError,
+ indexerFlagsError,
+ systemStatusError,
+ translationsError,
+ },
+ };
+ }
+ );
+
+const useAppPage = () => {
+ const dispatch = useDispatch();
+
+ const isPopulated = useSelector(
+ (state: AppState) =>
+ state.movies.isPopulated &&
+ state.movieCollections.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 = 'radarrTest';
+
+ try {
+ localStorage.setItem(key, key);
+ localStorage.removeItem(key);
+
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ useEffect(() => {
+ dispatch(fetchMovies());
+ dispatch(fetchMovieCollections());
+ 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 0000000000..b5690d2d87
--- /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');
+ },
+ },
+
+ focusMovieSearchInput: {
+ 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/Movie/Index/MovieIndex.tsx b/frontend/src/Movie/Index/MovieIndex.tsx
index e35f4364ed..59fde28f12 100644
--- a/frontend/src/Movie/Index/MovieIndex.tsx
+++ b/frontend/src/Movie/Index/MovieIndex.tsx
@@ -15,7 +15,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';
@@ -194,10 +194,11 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
[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/Movie/Index/Overview/MovieIndexOverviewInfo.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx
index 32221a5dc6..226f15775d 100644
--- a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.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';
@@ -20,7 +20,7 @@ interface RowProps {
interface RowInfoProps {
title: string;
- iconName: IconDefinition;
+ iconName: IconName;
label: string;
}
diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx
index 48f96d5fd7..bd6ad9f9e8 100644
--- a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx
+++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx
@@ -1,10 +1,10 @@
import React from 'react';
-import Icon, { IconProps } from 'Components/Icon';
+import Icon, { IconName } from 'Components/Icon';
import styles from './MovieIndexOverviewInfoRow.css';
interface MovieIndexOverviewInfoRowProps {
title?: string;
- iconName: IconProps['name'];
+ iconName: IconName;
label: string;
}
diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx
index 1965611198..0eb74427ca 100644
--- a/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx
+++ b/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.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 MovieIndexSelectAllButtonProps {
- label: string;
+interface MovieIndexSelectAllButtonProps
+ extends Omit {
isSelectMode: boolean;
- overflowComponent: React.FunctionComponent;
}
function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
- const { isSelectMode } = props;
+ const { isSelectMode, overflowComponent } = props;
const [selectState, selectDispatch] = useSelect();
const { allSelected, allUnselected } = selectState;
@@ -33,6 +34,7 @@ function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) {
) : null;
diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx
index 35e25b9c93..4b69003e0f 100644
--- a/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx
+++ b/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.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 MovieIndexSelectModeButtonProps {
- label: string;
- iconName: IconDefinition;
+interface MovieIndexSelectModeButtonProps extends PageToolbarButtonProps {
isSelectMode: boolean;
- overflowComponent: React.FunctionComponent;
onPress: () => void;
}
function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
- const { label, iconName, isSelectMode, onPress } = props;
+ const { label, iconName, isSelectMode, overflowComponent, onPress } = props;
const [, selectDispatch] = useSelect();
const onPressWrapper = useCallback(() => {
@@ -29,6 +27,7 @@ function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) {
);
diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx
index 29d8879db8..0cadc2f03e 100644
--- a/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx
+++ b/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.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 MovieIndexSelectModeMenuItemProps {
label: string;
- iconName: IconDefinition;
+ iconName: IconName;
isSelectMode: boolean;
onPress: () => void;
}
diff --git a/frontend/src/Store/Selectors/createTagsSelector.ts b/frontend/src/Store/Selectors/createTagsSelector.ts
index f653ff6e36..2ec629ceb7 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 72474b8536..fac23f50a8 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 | undefined, fallbackErrorMessage = '') {
if (!xhr || !xhr.responseJSON) {
return fallbackErrorMessage;
}
diff --git a/frontend/src/typings/Settings/UiSettings.ts b/frontend/src/typings/Settings/UiSettings.ts
index 8e5765a4de..2fb41b092b 100644
--- a/frontend/src/typings/Settings/UiSettings.ts
+++ b/frontend/src/typings/Settings/UiSettings.ts
@@ -4,6 +4,7 @@ export default interface UiSettings {
shortDateFormat: string;
longDateFormat: string;
timeFormat: string;
+ enableColorImpairedMode: boolean;
movieRuntimeFormat: string;
movieInfoLanguage: number;
uiLanguage: number;
diff --git a/frontend/typings/worker-loader.d.ts b/frontend/typings/worker-loader.d.ts
new file mode 100644
index 0000000000..3dac70cb58
--- /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 d7baf099e7..d5d3aec40f 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,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",
@@ -96,6 +96,7 @@
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@types/lodash": "4.14.195",
+ "@types/mousetrap": "1.6.15",
"@types/react-autosuggest": "10.1.11",
"@types/react-document-title": "2.0.10",
"@types/react-google-recaptcha": "2.1.9",
diff --git a/yarn.lock b/yarn.lock
index 353467d4d0..4de96d04a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1357,6 +1357,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"
@@ -3556,10 +3561,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"