1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-22 22:16:13 -04:00

Convert Page components to TypeScript

This commit is contained in:
Mark McDowall
2024-12-16 06:51:45 -08:00
parent 4e65669c48
commit f35a27449d
69 changed files with 2423 additions and 2755 deletions
@@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector';
function KeyboardShortcutsModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
size={sizes.SMALL}
onModalClose={onModalClose}
>
<KeyboardShortcutsModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
KeyboardShortcutsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default KeyboardShortcutsModal;
@@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function KeyboardShortcutsModal(props: KeyboardShortcutsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} size={sizes.SMALL} onModalClose={onModalClose}>
<KeyboardShortcutsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default KeyboardShortcutsModal;
@@ -1,16 +1,17 @@
import PropTypes from 'prop-types';
import React from 'react';
import { shortcuts } from 'Components/keyboardShortcuts';
import { useSelector } from 'react-redux';
import { Shortcut, shortcuts } from 'Components/keyboardShortcuts';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import translate from 'Utilities/String/translate';
import styles from './KeyboardShortcutsModalContent.css';
function getShortcuts() {
const allShortcuts = [];
const allShortcuts: Shortcut[] = [];
Object.keys(shortcuts).forEach((key) => {
allShortcuts.push(shortcuts[key]);
@@ -19,7 +20,7 @@ function getShortcuts() {
return allShortcuts;
}
function getShortcutKey(combo, isOsx) {
function getShortcutKey(combo: string, isOsx: boolean) {
const comboMatch = combo.match(/(.+?)\+(.)/);
if (!comboMatch) {
@@ -37,55 +38,39 @@ function getShortcutKey(combo, isOsx) {
return `${osModifier} + ${key}`;
}
function KeyboardShortcutsModalContent(props) {
const {
isOsx,
onModalClose
} = props;
interface KeyboardShortcutsModalContentProps {
onModalClose: () => void;
}
function KeyboardShortcutsModalContent({
onModalClose,
}: KeyboardShortcutsModalContentProps) {
const { isOsx } = useSelector(createSystemStatusSelector());
const allShortcuts = getShortcuts();
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('KeyboardShortcuts')}
</ModalHeader>
<ModalHeader>{translate('KeyboardShortcuts')}</ModalHeader>
<ModalBody>
{
allShortcuts.map((shortcut) => {
return (
<div
key={shortcut.name}
className={styles.shortcut}
>
<div className={styles.key}>
{getShortcutKey(shortcut.key, isOsx)}
</div>
<div>
{shortcut.name}
</div>
{allShortcuts.map((shortcut) => {
return (
<div key={shortcut.name} className={styles.shortcut}>
<div className={styles.key}>
{getShortcutKey(shortcut.key, isOsx)}
</div>
);
})
}
<div>{shortcut.name}</div>
</div>
);
})}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
KeyboardShortcutsModalContent.propTypes = {
isOsx: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default KeyboardShortcutsModalContent;
@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
function createMapStateToProps() {
return createSelector(
createSystemStatusSelector(),
(systemStatus) => {
return {
isOsx: systemStatus.isOsx
};
}
);
}
export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);
@@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
import styles from './PageHeader.css';
class PageHeader extends Component {
//
// Lifecycle
constructor(props, context) {
super(props);
this.state = {
isKeyboardShortcutsModalOpen: false
};
}
componentDidMount() {
this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
}
//
// Control
onOpenKeyboardShortcutsModal = () => {
this.setState({ isKeyboardShortcutsModalOpen: true });
};
//
// Listeners
onKeyboardShortcutsModalClose = () => {
this.setState({ isKeyboardShortcutsModalOpen: false });
};
//
// Render
render() {
const {
onSidebarToggle
} = this.props;
return (
<div className={styles.header}>
<div className={styles.logoContainer}>
<Link
className={styles.logoLink}
to={'/'}
>
<img
className={styles.logo}
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
alt="Sonarr Logo"
/>
</Link>
</div>
<div className={styles.sidebarToggleContainer}>
<IconButton
id="sidebar-toggle-button"
name={icons.NAVBAR_COLLAPSE}
onPress={onSidebarToggle}
/>
</div>
<SeriesSearchInputConnector />
<div className={styles.right}>
<IconButton
className={styles.donate}
name={icons.HEART}
aria-label={translate('Donate')}
to="https://sonarr.tv/donate.html"
size={14}
title={translate('Donate')}
/>
<PageHeaderActionsMenu
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>
<KeyboardShortcutsModal
isOpen={this.state.isKeyboardShortcutsModalOpen}
onModalClose={this.onKeyboardShortcutsModalClose}
/>
</div>
);
}
}
PageHeader.propTypes = {
onSidebarToggle: PropTypes.func.isRequired,
bindShortcut: PropTypes.func.isRequired
};
export default keyboardShortcuts(PageHeader);
@@ -0,0 +1,93 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import { setIsSidebarVisible } from 'Store/Actions/appActions';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import SeriesSearchInput from './SeriesSearchInput';
import styles from './PageHeader.css';
function PageHeader() {
const dispatch = useDispatch();
const { isSidebarVisible } = useSelector((state: AppState) => state.app);
const [isKeyboardShortcutsModalOpen, setIsKeyboardShortcutsModalOpen] =
useState(false);
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
const handleSidebarToggle = useCallback(() => {
dispatch(setIsSidebarVisible({ isSidebarVisible: !isSidebarVisible }));
}, [isSidebarVisible, dispatch]);
const handleOpenKeyboardShortcutsModal = useCallback(() => {
setIsKeyboardShortcutsModalOpen(true);
}, []);
const handleKeyboardShortcutsModalClose = useCallback(() => {
setIsKeyboardShortcutsModalOpen(false);
}, []);
useEffect(() => {
bindShortcut(
'openKeyboardShortcutsModal',
handleOpenKeyboardShortcutsModal
);
return () => {
unbindShortcut('openKeyboardShortcutsModal');
};
}, [handleOpenKeyboardShortcutsModal, bindShortcut, unbindShortcut]);
return (
<div className={styles.header}>
<div className={styles.logoContainer}>
<Link className={styles.logoLink} to="/">
<img
className={styles.logo}
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
alt="Sonarr Logo"
/>
</Link>
</div>
<div className={styles.sidebarToggleContainer}>
<IconButton
id="sidebar-toggle-button"
name={icons.NAVBAR_COLLAPSE}
onPress={handleSidebarToggle}
/>
</div>
<SeriesSearchInput />
<div className={styles.right}>
<IconButton
className={styles.donate}
name={icons.HEART}
aria-label={translate('Donate')}
to="https://sonarr.tv/donate.html"
size={14}
title={translate('Donate')}
/>
<PageHeaderActionsMenu
onKeyboardShortcutsPress={handleOpenKeyboardShortcutsModal}
/>
</div>
<KeyboardShortcutsModal
isOpen={isKeyboardShortcutsModalOpen}
onModalClose={handleKeyboardShortcutsModalClose}
/>
</div>
);
}
export default PageHeader;
@@ -1,346 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import Icon from 'Components/Icon';
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FuseWorker from './fuse.worker';
import SeriesSearchResult from './SeriesSearchResult';
import styles from './SeriesSearchInput.css';
const ADD_NEW_TYPE = 'addNew';
class SeriesSearchInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._autosuggest = null;
this._worker = null;
this.state = {
value: '',
suggestions: []
};
}
componentDidMount() {
this.props.bindShortcut(shortcuts.SERIES_SEARCH_INPUT.key, this.focusInput);
}
componentWillUnmount() {
if (this._worker) {
this._worker.removeEventListener('message', this.onSuggestionsReceived, false);
this._worker.terminate();
this._worker = null;
}
}
getWorker() {
if (!this._worker) {
this._worker = new FuseWorker();
this._worker.addEventListener('message', this.onSuggestionsReceived, false);
}
return this._worker;
}
//
// Control
setAutosuggestRef = (ref) => {
this._autosuggest = ref;
};
focusInput = (event) => {
event.preventDefault();
this._autosuggest.input.focus();
};
getSectionSuggestions(section) {
return section.suggestions;
}
renderSectionTitle(section) {
return (
<div className={styles.sectionTitle}>
{section.title}
{
section.loading &&
<LoadingIndicator
className={styles.loading}
rippleClassName={styles.ripple}
size={20}
/>
}
</div>
);
}
getSuggestionValue({ title }) {
return title;
}
renderSuggestion(item, { query }) {
if (item.type === ADD_NEW_TYPE) {
return (
<div className={styles.addNewSeriesSuggestion}>
{translate('SearchForQuery', { query })}
</div>
);
}
return (
<SeriesSearchResult
{...item.item}
match={item.matches[0]}
/>
);
}
goToSeries(item) {
this.setState({ value: '' });
this.props.onGoToSeries(item.item.titleSlug);
}
reset() {
this.setState({
value: '',
suggestions: [],
loading: false
});
}
//
// Listeners
onChange = (event, { newValue, method }) => {
if (method === 'up' || method === 'down') {
return;
}
this.setState({ value: newValue });
};
onKeyDown = (event) => {
if (event.shiftKey || event.altKey || event.ctrlKey) {
return;
}
if (event.key === 'Escape') {
this.reset();
return;
}
if (event.key !== 'Tab' && event.key !== 'Enter') {
return;
}
const {
suggestions,
value
} = this.state;
const {
highlightedSectionIndex,
highlightedSuggestionIndex
} = this._autosuggest.state;
if (!suggestions.length || highlightedSectionIndex) {
this.props.onGoToAddNewSeries(value);
this._autosuggest.input.blur();
this.reset();
return;
}
// If an suggestion is not selected go to the first series,
// otherwise go to the selected series.
if (highlightedSuggestionIndex == null) {
this.goToSeries(suggestions[0]);
} else {
this.goToSeries(suggestions[highlightedSuggestionIndex]);
}
this._autosuggest.input.blur();
this.reset();
};
onBlur = () => {
this.reset();
};
onSuggestionsFetchRequested = ({ value }) => {
if (!this.state.loading) {
this.setState({
loading: true
});
}
this.requestSuggestions(value);
};
requestSuggestions = _.debounce((value) => {
if (!this.state.loading) {
return;
}
const requestLoading = this.state.requestLoading;
this.setState({
requestValue: value,
requestLoading: true
});
if (!requestLoading) {
const payload = {
value,
series: this.props.series
};
this.getWorker().postMessage(payload);
}
}, 250);
onSuggestionsReceived = (message) => {
const {
value,
suggestions
} = message.data;
if (!this.state.loading) {
this.setState({
requestValue: null,
requestLoading: false
});
} else if (value === this.state.requestValue) {
this.setState({
suggestions,
requestValue: null,
requestLoading: false,
loading: false
});
} else {
this.setState({
suggestions,
requestLoading: true
});
const payload = {
value: this.state.requestValue,
series: this.props.series
};
this.getWorker().postMessage(payload);
}
};
onSuggestionsClearRequested = () => {
this.setState({
suggestions: [],
loading: false
});
};
onSuggestionSelected = (event, { suggestion }) => {
if (suggestion.type === ADD_NEW_TYPE) {
this.props.onGoToAddNewSeries(this.state.value);
} else {
this.goToSeries(suggestion);
}
};
//
// Render
render() {
const {
value,
loading,
suggestions
} = this.state;
const suggestionGroups = [];
if (suggestions.length || loading) {
suggestionGroups.push({
title: translate('ExistingSeries'),
loading,
suggestions
});
}
suggestionGroups.push({
title: translate('AddNewSeries'),
suggestions: [
{
type: ADD_NEW_TYPE,
title: value
}
]
});
const inputProps = {
ref: this.setInputRef,
className: styles.input,
name: 'seriesSearch',
value,
placeholder: translate('Search'),
autoComplete: 'off',
spellCheck: false,
onChange: this.onChange,
onKeyDown: this.onKeyDown,
onBlur: this.onBlur,
onFocus: this.onFocus
};
const theme = {
container: styles.container,
containerOpen: styles.containerOpen,
suggestionsContainer: styles.seriesContainer,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return (
<div className={styles.wrapper}>
<Icon name={icons.SEARCH} />
<Autosuggest
ref={this.setAutosuggestRef}
id={name}
inputProps={inputProps}
theme={theme}
focusInputOnSuggestionClick={false}
multiSection={true}
suggestions={suggestionGroups}
getSectionSuggestions={this.getSectionSuggestions}
renderSectionTitle={this.renderSectionTitle}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
</div>
);
}
}
SeriesSearchInput.propTypes = {
series: PropTypes.arrayOf(PropTypes.object).isRequired,
onGoToSeries: PropTypes.func.isRequired,
onGoToAddNewSeries: PropTypes.func.isRequired,
bindShortcut: PropTypes.func.isRequired
};
export default keyboardShortcuts(SeriesSearchInput);
@@ -0,0 +1,460 @@
import { push } from 'connected-react-router';
import { ExtendedKeyboardEvent } from 'mousetrap';
import React, {
FormEvent,
KeyboardEvent,
SyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Autosuggest from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
import SeriesSearchResult from './SeriesSearchResult';
import styles from './SeriesSearchInput.css';
const ADD_NEW_TYPE = 'addNew';
interface Match {
key: string;
refIndex: number;
}
interface AddNewSeriesSuggestion {
type: 'addNew';
title: string;
}
export interface SuggestedSeries
extends Pick<
Series,
| 'title'
| 'titleSlug'
| 'sortTitle'
| 'images'
| 'alternateTitles'
| 'tvdbId'
| 'tvMazeId'
| 'imdbId'
| 'tmdbId'
> {
firstCharacter: string;
tags: Tag[];
}
interface SeriesSuggestion {
title: string;
indices: number[];
item: SuggestedSeries;
matches: Match[];
refIndex: number;
}
interface Section {
title: string;
loading?: boolean;
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
}
function createUnoptimizedSelector() {
return createSelector(
createAllSeriesSelector(),
createTagsSelector(),
(allSeries, allTags) => {
return allSeries.map((series): SuggestedSeries => {
const {
title,
titleSlug,
sortTitle,
images,
alternateTitles = [],
tvdbId,
tvMazeId,
imdbId,
tmdbId,
tags = [],
} = series;
return {
title,
titleSlug,
sortTitle,
images,
alternateTitles,
tvdbId,
tvMazeId,
imdbId,
tmdbId,
firstCharacter: title.charAt(0).toLowerCase(),
tags: tags.reduce<Tag[]>((acc, id) => {
const matchingTag = allTags.find((tag) => tag.id === id);
if (matchingTag) {
acc.push(matchingTag);
}
return acc;
}, []),
};
});
}
);
}
function createSeriesSelector() {
return createDeepEqualSelector(
createUnoptimizedSelector(),
(series) => series
);
}
function SeriesSearchInput() {
const series = useSelector(createSeriesSelector());
const dispatch = useDispatch();
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
const [value, setValue] = useState('');
const [requestLoading, setRequestLoading] = useState(false);
const [suggestions, setSuggestions] = useState<SeriesSuggestion[]>([]);
const autosuggestRef = useRef<Autosuggest>(null);
const inputRef = useRef<HTMLInputElement>(null);
const worker = useRef<Worker | null>(null);
const isLoading = useRef(false);
const requestValue = useRef<string | null>(null);
const suggestionGroups = useMemo(() => {
const result: Section[] = [];
if (suggestions.length || isLoading.current) {
result.push({
title: translate('ExistingSeries'),
loading: isLoading.current,
suggestions,
});
}
result.push({
title: translate('AddNewSeries'),
suggestions: [
{
type: ADD_NEW_TYPE,
title: value,
},
],
});
return result;
}, [suggestions, value]);
const handleSuggestionsReceived = useCallback(
(message: { data: { value: string; suggestions: SeriesSuggestion[] } }) => {
const { value, suggestions } = message.data;
if (!isLoading.current) {
requestValue.current = null;
setRequestLoading(false);
} else if (value === requestValue.current) {
setSuggestions(suggestions);
requestValue.current = null;
setRequestLoading(false);
isLoading.current = false;
// setLoading(false);
} else {
setSuggestions(suggestions);
setRequestLoading(true);
const payload = {
value: requestValue,
series,
};
worker.current?.postMessage(payload);
}
},
[series]
);
const requestSuggestions = useDebouncedCallback((value: string) => {
if (!isLoading.current) {
return;
}
requestValue.current = value;
setRequestLoading(true);
if (!requestLoading) {
const payload = {
value,
series,
};
worker.current?.postMessage(payload);
}
}, 250);
const reset = useCallback(() => {
setValue('');
setSuggestions([]);
// setLoading(false);
isLoading.current = false;
}, []);
const focusInput = useCallback((event: ExtendedKeyboardEvent) => {
event.preventDefault();
inputRef.current?.focus();
}, []);
const getSectionSuggestions = useCallback((section: Section) => {
return section.suggestions;
}, []);
const renderSectionTitle = useCallback((section: Section) => {
return (
<div className={styles.sectionTitle}>
{section.title}
{section.loading && (
<LoadingIndicator
className={styles.loading}
rippleClassName={styles.ripple}
size={20}
/>
)}
</div>
);
}, []);
const getSuggestionValue = useCallback(({ title }: { title: string }) => {
return title;
}, []);
const renderSuggestion = useCallback(
(
item: AddNewSeriesSuggestion | SeriesSuggestion,
{ query }: { query: string }
) => {
if ('type' in item) {
return (
<div className={styles.addNewSeriesSuggestion}>
{translate('SearchForQuery', { query })}
</div>
);
}
return <SeriesSearchResult {...item.item} match={item.matches[0]} />;
},
[]
);
const handleChange = useCallback(
(
_event: FormEvent<HTMLElement>,
{
newValue,
method,
}: {
newValue: string;
method: 'down' | 'up' | 'escape' | 'enter' | 'click' | 'type';
}
) => {
if (method === 'up' || method === 'down') {
return;
}
setValue(newValue);
},
[]
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (event.shiftKey || event.altKey || event.ctrlKey) {
return;
}
if (event.key === 'Escape') {
reset();
return;
}
if (event.key !== 'Tab' && event.key !== 'Enter') {
return;
}
if (!autosuggestRef.current) {
return;
}
const { highlightedSectionIndex, highlightedSuggestionIndex } =
autosuggestRef.current.state;
if (!suggestions.length || highlightedSectionIndex) {
dispatch(
push(
`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
)
);
inputRef.current?.blur();
reset();
return;
}
// If an suggestion is not selected go to the first series,
// otherwise go to the selected series.
const selectedSuggestion =
highlightedSuggestionIndex == null
? suggestions[0]
: suggestions[highlightedSuggestionIndex];
dispatch(
push(
`${window.Sonarr.urlBase}/series/${selectedSuggestion.item.titleSlug}`
)
);
inputRef.current?.blur();
reset();
},
[value, suggestions, dispatch, reset]
);
const handleBlur = useCallback(() => {
reset();
}, [reset]);
const handleSuggestionsFetchRequested = useCallback(
({ value }: { value: string }) => {
isLoading.current = true;
requestSuggestions(value);
},
[requestSuggestions]
);
const handleSuggestionsClearRequested = useCallback(() => {
setSuggestions([]);
isLoading.current = false;
}, []);
const handleSuggestionSelected = useCallback(
(
_event: SyntheticEvent,
{ suggestion }: { suggestion: SeriesSuggestion | AddNewSeriesSuggestion }
) => {
if ('type' in suggestion) {
dispatch(
push(
`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
)
);
} else {
setValue('');
dispatch(
push(`${window.Sonarr.urlBase}/series/${suggestion.item.titleSlug}`)
);
}
},
[value, dispatch]
);
const inputProps = {
ref: inputRef,
className: styles.input,
name: 'seriesSearch',
value,
placeholder: translate('Search'),
autoComplete: 'off',
spellCheck: false,
onChange: handleChange,
onKeyDown: handleKeyDown,
onBlur: handleBlur,
};
const theme = {
container: styles.container,
containerOpen: styles.containerOpen,
suggestionsContainer: styles.seriesContainer,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted,
};
useEffect(() => {
worker.current = new Worker(new URL('./fuse.worker.ts', import.meta.url));
return () => {
if (worker.current) {
worker.current.terminate();
worker.current = null;
}
};
}, []);
useEffect(() => {
worker.current?.addEventListener(
'message',
handleSuggestionsReceived,
false
);
return () => {
if (worker.current) {
worker.current.removeEventListener(
'message',
handleSuggestionsReceived,
false
);
}
};
}, [handleSuggestionsReceived]);
useEffect(() => {
bindShortcut('focusSeriesSearchInput', focusInput);
return () => {
unbindShortcut('focusSeriesSearchInput');
};
}, [bindShortcut, unbindShortcut, focusInput]);
return (
<div className={styles.wrapper}>
<Icon name={icons.SEARCH} />
<Autosuggest
ref={autosuggestRef}
inputProps={inputProps}
theme={theme}
focusInputOnSuggestionClick={false}
multiSection={true}
suggestions={suggestionGroups}
getSectionSuggestions={getSectionSuggestions}
renderSectionTitle={renderSectionTitle}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onSuggestionSelected={handleSuggestionSelected}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
</div>
);
}
export default SeriesSearchInput;
@@ -1,77 +0,0 @@
import { push } from 'connected-react-router';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import SeriesSearchInput from './SeriesSearchInput';
function createCleanSeriesSelector() {
return createSelector(
createAllSeriesSelector(),
createTagsSelector(),
(allSeries, allTags) => {
return allSeries.map((series) => {
const {
title,
titleSlug,
sortTitle,
images,
alternateTitles = [],
tvdbId,
tvMazeId,
imdbId,
tmdbId,
tags = []
} = series;
return {
title,
titleSlug,
sortTitle,
images,
alternateTitles,
tvdbId,
tvMazeId,
imdbId,
tmdbId,
firstCharacter: title.charAt(0).toLowerCase(),
tags: tags.reduce((acc, id) => {
const matchingTag = allTags.find((tag) => tag.id === id);
if (matchingTag) {
acc.push(matchingTag);
}
return acc;
}, [])
};
});
}
);
}
function createMapStateToProps() {
return createDeepEqualSelector(
createCleanSeriesSelector(),
(series) => {
return {
series
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onGoToSeries(titleSlug) {
dispatch(push(`${window.Sonarr.urlBase}/series/${titleSlug}`));
},
onGoToAddNewSeries(query) {
dispatch(push(`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesSearchInput);
@@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import SeriesPoster from 'Series/SeriesPoster';
import styles from './SeriesSearchResult.css';
function SeriesSearchResult(props) {
const {
match,
title,
images,
alternateTitles,
tvdbId,
tvMazeId,
imdbId,
tmdbId,
tags
} = props;
let alternateTitle = null;
let tag = null;
if (match.key === 'alternateTitles.title') {
alternateTitle = alternateTitles[match.refIndex];
} else if (match.key === 'tags.label') {
tag = tags[match.refIndex];
}
return (
<div className={styles.result}>
<SeriesPoster
className={styles.poster}
images={images}
size={250}
lazy={false}
overflow={true}
/>
<div className={styles.titles}>
<div className={styles.title}>
{title}
</div>
{
alternateTitle ?
<div className={styles.alternateTitle}>
{alternateTitle.title}
</div> :
null
}
{
match.key === 'tvdbId' && tvdbId ?
<div className={styles.alternateTitle}>
TvdbId: {tvdbId}
</div> :
null
}
{
match.key === 'tvMazeId' && tvMazeId ?
<div className={styles.alternateTitle}>
TvMazeId: {tvMazeId}
</div> :
null
}
{
match.key === 'imdbId' && imdbId ?
<div className={styles.alternateTitle}>
ImdbId: {imdbId}
</div> :
null
}
{
match.key === 'tmdbId' && tmdbId ?
<div className={styles.alternateTitle}>
TmdbId: {tmdbId}
</div> :
null
}
{
tag ?
<div className={styles.tagContainer}>
<Label
key={tag.id}
kind={kinds.INFO}
>
{tag.label}
</Label>
</div> :
null
}
</div>
</div>
);
}
SeriesSearchResult.propTypes = {
title: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
tvdbId: PropTypes.number,
tvMazeId: PropTypes.number,
imdbId: PropTypes.string,
tmdbId: PropTypes.number,
tags: PropTypes.arrayOf(PropTypes.object).isRequired,
match: PropTypes.object.isRequired
};
export default SeriesSearchResult;
@@ -0,0 +1,85 @@
import React from 'react';
import { Tag } from 'App/State/TagsAppState';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import SeriesPoster from 'Series/SeriesPoster';
import { SuggestedSeries } from './SeriesSearchInput';
import styles from './SeriesSearchResult.css';
interface Match {
key: string;
refIndex: number;
}
interface SeriesSearchResultProps extends SuggestedSeries {
match: Match;
}
function SeriesSearchResult(props: SeriesSearchResultProps) {
const {
match,
title,
images,
alternateTitles,
tvdbId,
tvMazeId,
imdbId,
tmdbId,
tags,
} = props;
let alternateTitle = null;
let tag: Tag | null = null;
if (match.key === 'alternateTitles.title') {
alternateTitle = alternateTitles[match.refIndex];
} else if (match.key === 'tags.label') {
tag = tags[match.refIndex];
}
return (
<div className={styles.result}>
<SeriesPoster
className={styles.poster}
images={images}
size={250}
lazy={false}
overflow={true}
/>
<div className={styles.titles}>
<div className={styles.title}>{title}</div>
{alternateTitle ? (
<div className={styles.alternateTitle}>{alternateTitle.title}</div>
) : null}
{match.key === 'tvdbId' && tvdbId ? (
<div className={styles.alternateTitle}>TvdbId: {tvdbId}</div>
) : null}
{match.key === 'tvMazeId' && tvMazeId ? (
<div className={styles.alternateTitle}>TvMazeId: {tvMazeId}</div>
) : null}
{match.key === 'imdbId' && imdbId ? (
<div className={styles.alternateTitle}>ImdbId: {imdbId}</div>
) : null}
{match.key === 'tmdbId' && tmdbId ? (
<div className={styles.alternateTitle}>TmdbId: {tmdbId}</div>
) : null}
{tag ? (
<div className={styles.tagContainer}>
<Label key={tag.id} kind={kinds.INFO}>
{tag.label}
</Label>
</div>
) : null}
</div>
</div>
);
}
export default SeriesSearchResult;
@@ -1,4 +1,7 @@
// eslint-disable filenames/match-exported
import Fuse from 'fuse.js';
import { SuggestedSeries } from './SeriesSearchInput';
const fuseOptions = {
shouldSort: true,
@@ -14,11 +17,11 @@ const fuseOptions = {
'tvMazeId',
'imdbId',
'tmdbId',
'tags.label'
]
'tags.label',
],
};
function getSuggestions(series, value) {
function getSuggestions(series: SuggestedSeries[], value: string) {
const limit = 10;
let suggestions = [];
@@ -28,16 +31,14 @@ function getSuggestions(series, value) {
if (s.firstCharacter === value.toLowerCase()) {
suggestions.push({
item: series[i],
indices: [
[0, 0]
],
indices: [[0, 0]],
matches: [
{
value: s.title,
key: 'title'
}
key: 'title',
},
],
refIndex: 0
refIndex: 0,
});
if (suggestions.length > limit) {
break;
@@ -52,21 +53,18 @@ function getSuggestions(series, value) {
return suggestions;
}
onmessage = function(e) {
onmessage = function (e) {
if (!e) {
return;
}
const {
series,
value
} = e.data;
const { series, value } = e.data;
const suggestions = getSuggestions(series, value);
const results = {
value,
suggestions
suggestions,
};
self.postMessage(results);