-
-
+
+
+
+ {translate('ActiveIndexers')}
+
+
{indexerCount}
+
+
+
+
+
+ {translate('TotalQueries')}
+
+
+ {abbreviateNumber(queryCount)}
+
+
+
+
+
+
+ {translate('TotalGrabs')}
+
+
{abbreviateNumber(grabCount)}
+
+
+
+
+
+ {translate('ActiveApps')}
+
+
{userAgentCount}
+
+
)}
diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx
deleted file mode 100644
index 7b30be4c3..000000000
--- a/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React from 'react';
-import FilterMenu from 'Components/Menu/FilterMenu';
-import { align } from 'Helpers/Props';
-
-interface IndexerStatsFilterMenuProps {
- selectedFilterKey: string | number;
- filters: object[];
- isDisabled: boolean;
- onFilterSelect(filterName: string): unknown;
-}
-
-function IndexerStatsFilterMenu(props: IndexerStatsFilterMenuProps) {
- const { selectedFilterKey, filters, isDisabled, onFilterSelect } = props;
-
- return (
-
- );
-}
-
-export default IndexerStatsFilterMenu;
diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx
new file mode 100644
index 000000000..6e3a49dfb
--- /dev/null
+++ b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx
@@ -0,0 +1,56 @@
+import React, { useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FilterModal from 'Components/Filter/FilterModal';
+import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions';
+
+function createIndexerStatsSelector() {
+ return createSelector(
+ (state: AppState) => state.indexerStats.item,
+ (indexerStats) => {
+ return indexerStats;
+ }
+ );
+}
+
+function createFilterBuilderPropsSelector() {
+ return createSelector(
+ (state: AppState) => state.indexerStats.filterBuilderProps,
+ (filterBuilderProps) => {
+ return filterBuilderProps;
+ }
+ );
+}
+
+interface IndexerStatsFilterModalProps {
+ isOpen: boolean;
+}
+
+export default function IndexerStatsFilterModal(
+ props: IndexerStatsFilterModalProps
+) {
+ const sectionItems = [useSelector(createIndexerStatsSelector())];
+ const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
+ const customFilterType = 'indexerStats';
+
+ const dispatch = useDispatch();
+
+ const dispatchSetFilter = useCallback(
+ (payload: unknown) => {
+ dispatch(setIndexerStatsFilter(payload));
+ },
+ [dispatch]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Indexer/useIndexer.ts b/frontend/src/Indexer/useIndexer.ts
new file mode 100644
index 000000000..a1b2ffa9d
--- /dev/null
+++ b/frontend/src/Indexer/useIndexer.ts
@@ -0,0 +1,19 @@
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+export function createIndexerSelector(indexerId?: number) {
+ return createSelector(
+ (state: AppState) => state.indexers.itemMap,
+ (state: AppState) => state.indexers.items,
+ (itemMap, allIndexers) => {
+ return indexerId ? allIndexers[itemMap[indexerId]] : undefined;
+ }
+ );
+}
+
+function useIndexer(indexerId?: number) {
+ return useSelector(createIndexerSelector(indexerId));
+}
+
+export default useIndexer;
diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css b/frontend/src/Search/Mobile/SearchIndexOverview.css
index 4e184bd0a..e29ff1ef9 100644
--- a/frontend/src/Search/Mobile/SearchIndexOverview.css
+++ b/frontend/src/Search/Mobile/SearchIndexOverview.css
@@ -47,3 +47,42 @@ $hoverScale: 1.05;
right: 0;
white-space: nowrap;
}
+
+.downloadLink {
+ composes: link from '~Components/Link/Link.css';
+
+ margin: 0 2px;
+ width: 22px;
+ color: var(--textColor);
+ text-align: center;
+}
+
+.manualDownloadContent {
+ position: relative;
+ display: inline-block;
+ margin: 0 2px;
+ width: 22px;
+ height: 20.39px;
+ vertical-align: middle;
+ line-height: 20.39px;
+
+ &:hover {
+ color: var(--iconButtonHoverColor);
+ }
+}
+
+.interactiveIcon {
+ position: absolute;
+ top: 4px;
+ left: 0;
+ /* width: 100%; */
+ text-align: center;
+}
+
+.downloadIcon {
+ position: absolute;
+ top: 7px;
+ left: 8px;
+ /* width: 100%; */
+ text-align: center;
+}
diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts b/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts
index 266cf7fca..68256eb25 100644
--- a/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts
+++ b/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts
@@ -4,9 +4,13 @@ interface CssExports {
'actions': string;
'container': string;
'content': string;
+ 'downloadIcon': string;
+ 'downloadLink': string;
'indexerRow': string;
'info': string;
'infoRow': string;
+ 'interactiveIcon': string;
+ 'manualDownloadContent': string;
'title': string;
'titleRow': string;
}
diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.js b/frontend/src/Search/Mobile/SearchIndexOverview.js
deleted file mode 100644
index 1a14ae66c..000000000
--- a/frontend/src/Search/Mobile/SearchIndexOverview.js
+++ /dev/null
@@ -1,234 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import TextTruncate from 'react-text-truncate';
-import Label from 'Components/Label';
-import IconButton from 'Components/Link/IconButton';
-import Link from 'Components/Link/Link';
-import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
-import { icons, kinds } from 'Helpers/Props';
-import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
-import CategoryLabel from 'Search/Table/CategoryLabel';
-import Peers from 'Search/Table/Peers';
-import dimensions from 'Styles/Variables/dimensions';
-import formatAge from 'Utilities/Number/formatAge';
-import formatBytes from 'Utilities/Number/formatBytes';
-import titleCase from 'Utilities/String/titleCase';
-import translate from 'Utilities/String/translate';
-import styles from './SearchIndexOverview.css';
-
-const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
-const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
-
-function getContentHeight(rowHeight, isSmallScreen) {
- const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
-
- return rowHeight - padding;
-}
-
-function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
- if (isGrabbing) {
- return icons.SPINNER;
- } else if (isGrabbed) {
- return icons.DOWNLOADING;
- } else if (grabError) {
- return icons.DOWNLOADING;
- }
-
- return icons.DOWNLOAD;
-}
-
-function getDownloadKind(isGrabbed, grabError) {
- if (isGrabbed) {
- return kinds.SUCCESS;
- }
-
- if (grabError) {
- return kinds.DANGER;
- }
-
- return kinds.DEFAULT;
-}
-
-function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
- if (isGrabbing) {
- return '';
- } else if (isGrabbed) {
- return translate('AddedToDownloadClient');
- } else if (grabError) {
- return grabError;
- }
-
- return translate('AddToDownloadClient');
-}
-
-class SearchIndexOverview extends Component {
-
- //
- // Listeners
-
- onGrabPress = () => {
- const {
- guid,
- indexerId,
- onGrabPress
- } = this.props;
-
- onGrabPress({
- guid,
- indexerId
- });
- };
-
- //
- // Render
-
- render() {
- const {
- title,
- infoUrl,
- protocol,
- downloadUrl,
- magnetUrl,
- categories,
- seeders,
- leechers,
- indexerFlags,
- size,
- age,
- ageHours,
- ageMinutes,
- indexer,
- rowHeight,
- isSmallScreen,
- isGrabbed,
- isGrabbing,
- grabError
- } = this.props;
-
- const contentHeight = getContentHeight(rowHeight, isSmallScreen);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- downloadUrl || magnetUrl ?
- :
- null
- }
-
-
-
- {indexer}
-
-
-
-
- {
- protocol === 'torrent' &&
-
- }
-
-
-
-
-
-
-
- {
- indexerFlags.length ?
- indexerFlags
- .sort((a, b) => a.localeCompare(b))
- .map((flag, index) => {
- return (
-
- );
- }) :
- null
- }
-
-
-
-
- );
- }
-}
-
-SearchIndexOverview.propTypes = {
- guid: PropTypes.string.isRequired,
- categories: PropTypes.arrayOf(PropTypes.object).isRequired,
- protocol: PropTypes.string.isRequired,
- age: PropTypes.number.isRequired,
- ageHours: PropTypes.number.isRequired,
- ageMinutes: PropTypes.number.isRequired,
- publishDate: PropTypes.string.isRequired,
- title: PropTypes.string.isRequired,
- infoUrl: PropTypes.string.isRequired,
- downloadUrl: PropTypes.string,
- magnetUrl: PropTypes.string,
- indexerId: PropTypes.number.isRequired,
- indexer: PropTypes.string.isRequired,
- size: PropTypes.number.isRequired,
- files: PropTypes.number,
- grabs: PropTypes.number,
- seeders: PropTypes.number,
- leechers: PropTypes.number,
- indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired,
- rowHeight: PropTypes.number.isRequired,
- showRelativeDates: PropTypes.bool.isRequired,
- shortDateFormat: PropTypes.string.isRequired,
- longDateFormat: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
- isSmallScreen: PropTypes.bool.isRequired,
- onGrabPress: PropTypes.func.isRequired,
- isGrabbing: PropTypes.bool.isRequired,
- isGrabbed: PropTypes.bool.isRequired,
- grabError: PropTypes.string
-};
-
-SearchIndexOverview.defaultProps = {
- isGrabbing: false,
- isGrabbed: false
-};
-
-export default SearchIndexOverview;
diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.tsx b/frontend/src/Search/Mobile/SearchIndexOverview.tsx
new file mode 100644
index 000000000..21a42d70c
--- /dev/null
+++ b/frontend/src/Search/Mobile/SearchIndexOverview.tsx
@@ -0,0 +1,264 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import TextTruncate from 'react-text-truncate';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { icons, kinds } from 'Helpers/Props';
+import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
+import { IndexerCategory } from 'Indexer/Indexer';
+import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal';
+import CategoryLabel from 'Search/Table/CategoryLabel';
+import Peers from 'Search/Table/Peers';
+import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
+import dimensions from 'Styles/Variables/dimensions';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import formatBytes from 'Utilities/Number/formatBytes';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import styles from './SearchIndexOverview.css';
+
+const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(
+ dimensions.movieIndexColumnPaddingSmallScreen
+);
+
+function getDownloadIcon(
+ isGrabbing: boolean,
+ isGrabbed: boolean,
+ grabError?: string
+) {
+ if (isGrabbing) {
+ return icons.SPINNER;
+ } else if (isGrabbed) {
+ return icons.DOWNLOADING;
+ } else if (grabError) {
+ return icons.DOWNLOADING;
+ }
+
+ return icons.DOWNLOAD;
+}
+
+function getDownloadKind(isGrabbed: boolean, grabError?: string) {
+ if (isGrabbed) {
+ return kinds.SUCCESS;
+ }
+
+ if (grabError) {
+ return kinds.DANGER;
+ }
+
+ return kinds.DEFAULT;
+}
+
+function getDownloadTooltip(
+ isGrabbing: boolean,
+ isGrabbed: boolean,
+ grabError?: string
+) {
+ if (isGrabbing) {
+ return '';
+ } else if (isGrabbed) {
+ return translate('AddedToDownloadClient');
+ } else if (grabError) {
+ return grabError;
+ }
+
+ return translate('AddToDownloadClient');
+}
+
+interface SearchIndexOverviewProps {
+ guid: string;
+ protocol: DownloadProtocol;
+ age: number;
+ ageHours: number;
+ ageMinutes: number;
+ publishDate: string;
+ title: string;
+ infoUrl: string;
+ downloadUrl?: string;
+ magnetUrl?: string;
+ indexerId: number;
+ indexer: string;
+ categories: IndexerCategory[];
+ size: number;
+ seeders?: number;
+ leechers?: number;
+ indexerFlags: string[];
+ isGrabbing: boolean;
+ isGrabbed: boolean;
+ grabError?: string;
+ longDateFormat: string;
+ timeFormat: string;
+ rowHeight: number;
+ isSmallScreen: boolean;
+ onGrabPress(...args: unknown[]): void;
+}
+
+function SearchIndexOverview(props: SearchIndexOverviewProps) {
+ const {
+ guid,
+ indexerId,
+ protocol,
+ categories,
+ age,
+ ageHours,
+ ageMinutes,
+ publishDate,
+ title,
+ infoUrl,
+ downloadUrl,
+ magnetUrl,
+ indexer,
+ size,
+ seeders,
+ leechers,
+ indexerFlags = [],
+ isGrabbing = false,
+ isGrabbed = false,
+ grabError,
+ longDateFormat,
+ timeFormat,
+ rowHeight,
+ isSmallScreen,
+ onGrabPress,
+ } = props;
+
+ const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
+
+ const { items: downloadClients } = useSelector(
+ createEnabledDownloadClientsSelector(protocol)
+ );
+
+ const onGrabPressWrapper = useCallback(() => {
+ onGrabPress({
+ guid,
+ indexerId,
+ });
+ }, [guid, indexerId, onGrabPress]);
+
+ const onOverridePress = useCallback(() => {
+ setIsOverrideModalOpen(true);
+ }, [setIsOverrideModalOpen]);
+
+ const onOverrideModalClose = useCallback(() => {
+ setIsOverrideModalOpen(false);
+ }, [setIsOverrideModalOpen]);
+
+ const contentHeight = useMemo(() => {
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+
+ return rowHeight - padding;
+ }, [rowHeight, isSmallScreen]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {downloadClients.length > 1 ? (
+
+
+
+
+
+
+
+ ) : null}
+
+ {downloadUrl || magnetUrl ? (
+
+ ) : null}
+
+
+
{indexer}
+
+
+
+ {protocol === 'torrent' && (
+
+ )}
+
+
+
+
+
+
+
+ {indexerFlags.length
+ ? indexerFlags
+ .sort((a, b) =>
+ a.localeCompare(b, undefined, { numeric: true })
+ )
+ .map((flag, index) => {
+ return (
+
+ );
+ })
+ : null}
+
+
+
+
+
+
+ >
+ );
+}
+
+export default SearchIndexOverview;
diff --git a/frontend/src/Search/NoSearchResults.css b/frontend/src/Search/NoSearchResults.css
index eff6272f7..f17dd633e 100644
--- a/frontend/src/Search/NoSearchResults.css
+++ b/frontend/src/Search/NoSearchResults.css
@@ -1,4 +1,6 @@
.message {
+ composes: alert from '~Components/Alert.css';
+
margin-top: 10px;
margin-bottom: 30px;
text-align: center;
diff --git a/frontend/src/Search/NoSearchResults.tsx b/frontend/src/Search/NoSearchResults.tsx
index 4ffd1d7fd..46fbc85e0 100644
--- a/frontend/src/Search/NoSearchResults.tsx
+++ b/frontend/src/Search/NoSearchResults.tsx
@@ -1,4 +1,6 @@
import React from 'react';
+import Alert from 'Components/Alert';
+import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './NoSearchResults.css';
@@ -11,18 +13,16 @@ function NoSearchResults(props: NoSearchResultsProps) {
if (totalItems > 0) {
return (
-
-
- {translate('AllIndexersHiddenDueToFilter')}
-
-
+
+ {translate('AllSearchResultsHiddenByFilter')}
+
);
}
return (
-
-
{translate('NoSearchResultsFound')}
-
+
+ {translate('NoSearchResultsFound')}
+
);
}
diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx
new file mode 100644
index 000000000..7d623decd
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { sizes } from 'Helpers/Props';
+import SelectDownloadClientModalContent from './SelectDownloadClientModalContent';
+
+interface SelectDownloadClientModalProps {
+ isOpen: boolean;
+ protocol: DownloadProtocol;
+ modalTitle: string;
+ onDownloadClientSelect(downloadClientId: number): void;
+ onModalClose(): void;
+}
+
+function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
+ const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } =
+ props;
+
+ return (
+
+
+
+ );
+}
+
+export default SelectDownloadClientModal;
diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx
new file mode 100644
index 000000000..63e15808f
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import Alert from 'Components/Alert';
+import Form from 'Components/Form/Form';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+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 DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { kinds } from 'Helpers/Props';
+import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
+import translate from 'Utilities/String/translate';
+import SelectDownloadClientRow from './SelectDownloadClientRow';
+
+interface SelectDownloadClientModalContentProps {
+ protocol: DownloadProtocol;
+ modalTitle: string;
+ onDownloadClientSelect(downloadClientId: number): void;
+ onModalClose(): void;
+}
+
+function SelectDownloadClientModalContent(
+ props: SelectDownloadClientModalContentProps
+) {
+ const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props;
+
+ const { isFetching, isPopulated, error, items } = useSelector(
+ createEnabledDownloadClientsSelector(protocol)
+ );
+
+ return (
+
+
+ {translate('SelectDownloadClientModalTitle', { modalTitle })}
+
+
+
+ {isFetching ? : null}
+
+ {!isFetching && error ? (
+
+ {translate('DownloadClientsLoadError')}
+
+ ) : null}
+
+ {isPopulated && !error ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+export default SelectDownloadClientModalContent;
diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css
new file mode 100644
index 000000000..6525db977
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css
@@ -0,0 +1,6 @@
+.downloadClient {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px;
+ border-bottom: 1px solid var(--borderColor);
+}
diff --git a/frontend/src/History/Details/HistoryDetailsModal.css.d.ts b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
similarity index 83%
rename from frontend/src/History/Details/HistoryDetailsModal.css.d.ts
rename to frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
index a8cc499e2..10c2d3948 100644
--- a/frontend/src/History/Details/HistoryDetailsModal.css.d.ts
+++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
@@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
- 'markAsFailedButton': string;
+ 'downloadClient': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx
new file mode 100644
index 000000000..6f98d60b4
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx
@@ -0,0 +1,32 @@
+import React, { useCallback } from 'react';
+import Link from 'Components/Link/Link';
+import translate from 'Utilities/String/translate';
+import styles from './SelectDownloadClientRow.css';
+
+interface SelectSeasonRowProps {
+ id: number;
+ name: string;
+ priority: number;
+ onDownloadClientSelect(downloadClientId: number): unknown;
+}
+
+function SelectDownloadClientRow(props: SelectSeasonRowProps) {
+ const { id, name, priority, onDownloadClientSelect } = props;
+
+ const onSeasonSelectWrapper = useCallback(() => {
+ onDownloadClientSelect(id);
+ }, [id, onDownloadClientSelect]);
+
+ return (
+
+
{name}
+
{translate('PrioritySettings', { priority })}
+
+ );
+}
+
+export default SelectDownloadClientRow;
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.css b/frontend/src/Search/OverrideMatch/OverrideMatchData.css
new file mode 100644
index 000000000..bd4d2f788
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.css
@@ -0,0 +1,17 @@
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ width: 100%;
+}
+
+.placeholder {
+ display: inline-block;
+ margin: -2px 0;
+ width: 100%;
+ outline: 2px dashed var(--dangerColor);
+ outline-offset: -2px;
+}
+
+.optional {
+ outline: 2px dashed var(--gray);
+}
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts b/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts
new file mode 100644
index 000000000..dd3ac4575
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'link': string;
+ 'optional': string;
+ 'placeholder': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx
new file mode 100644
index 000000000..82d6bd812
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx
@@ -0,0 +1,35 @@
+import classNames from 'classnames';
+import React from 'react';
+import Link from 'Components/Link/Link';
+import styles from './OverrideMatchData.css';
+
+interface OverrideMatchDataProps {
+ value?: string | number | JSX.Element | JSX.Element[];
+ isDisabled?: boolean;
+ isOptional?: boolean;
+ onPress: () => void;
+}
+
+function OverrideMatchData(props: OverrideMatchDataProps) {
+ const { value, isDisabled = false, isOptional, onPress } = props;
+
+ return (
+
+ {(value == null || (Array.isArray(value) && value.length === 0)) &&
+ !isDisabled ? (
+
+
+
+ ) : (
+ value
+ )}
+
+ );
+}
+
+export default OverrideMatchData;
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx
new file mode 100644
index 000000000..16d62ea7c
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { sizes } from 'Helpers/Props';
+import OverrideMatchModalContent from './OverrideMatchModalContent';
+
+interface OverrideMatchModalProps {
+ isOpen: boolean;
+ title: string;
+ indexerId: number;
+ guid: string;
+ protocol: DownloadProtocol;
+ isGrabbing: boolean;
+ grabError?: string;
+ onModalClose(): void;
+}
+
+function OverrideMatchModal(props: OverrideMatchModalProps) {
+ const {
+ isOpen,
+ title,
+ indexerId,
+ guid,
+ protocol,
+ isGrabbing,
+ grabError,
+ onModalClose,
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default OverrideMatchModal;
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css
new file mode 100644
index 000000000..a5b4b8d52
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css
@@ -0,0 +1,49 @@
+.label {
+ composes: label from '~Components/Label.css';
+
+ cursor: pointer;
+}
+
+.item {
+ display: block;
+ margin-bottom: 5px;
+ margin-left: 50px;
+}
+
+.footer {
+ composes: modalFooter from '~Components/Modal/ModalFooter.css';
+
+ display: flex;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.error {
+ margin-right: 20px;
+ color: var(--dangerColor);
+ word-break: break-word;
+}
+
+.buttons {
+ display: flex;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .item {
+ margin-left: 0;
+ }
+
+ .footer {
+ display: block;
+ }
+
+ .error {
+ margin-right: 0;
+ margin-bottom: 10px;
+ }
+
+ .buttons {
+ justify-content: space-between;
+ flex-grow: 1;
+ }
+}
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts
new file mode 100644
index 000000000..79c77d6b5
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'buttons': string;
+ 'error': string;
+ 'footer': string;
+ 'item': string;
+ 'label': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx
new file mode 100644
index 000000000..fbe0ec450
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx
@@ -0,0 +1,150 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+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 DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { grabRelease } from 'Store/Actions/releaseActions';
+import { fetchDownloadClients } from 'Store/Actions/settingsActions';
+import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
+import translate from 'Utilities/String/translate';
+import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
+import OverrideMatchData from './OverrideMatchData';
+import styles from './OverrideMatchModalContent.css';
+
+type SelectType = 'select' | 'downloadClient';
+
+interface OverrideMatchModalContentProps {
+ indexerId: number;
+ title: string;
+ guid: string;
+ protocol: DownloadProtocol;
+ isGrabbing: boolean;
+ grabError?: string;
+ onModalClose(): void;
+}
+
+function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
+ const modalTitle = translate('ManualGrab');
+ const {
+ indexerId,
+ title,
+ guid,
+ protocol,
+ isGrabbing,
+ grabError,
+ onModalClose,
+ } = props;
+
+ const [downloadClientId, setDownloadClientId] = useState
(null);
+ const [selectModalOpen, setSelectModalOpen] = useState(
+ null
+ );
+ const previousIsGrabbing = usePrevious(isGrabbing);
+
+ const dispatch = useDispatch();
+ const { items: downloadClients } = useSelector(
+ createEnabledDownloadClientsSelector(protocol)
+ );
+
+ const onSelectModalClose = useCallback(() => {
+ setSelectModalOpen(null);
+ }, [setSelectModalOpen]);
+
+ const onSelectDownloadClientPress = useCallback(() => {
+ setSelectModalOpen('downloadClient');
+ }, [setSelectModalOpen]);
+
+ const onDownloadClientSelect = useCallback(
+ (downloadClientId: number) => {
+ setDownloadClientId(downloadClientId);
+ setSelectModalOpen(null);
+ },
+ [setDownloadClientId, setSelectModalOpen]
+ );
+
+ const onGrabPress = useCallback(() => {
+ dispatch(
+ grabRelease({
+ indexerId,
+ guid,
+ downloadClientId,
+ })
+ );
+ }, [indexerId, guid, downloadClientId, dispatch]);
+
+ useEffect(() => {
+ if (!isGrabbing && previousIsGrabbing) {
+ onModalClose();
+ }
+ }, [isGrabbing, previousIsGrabbing, onModalClose]);
+
+ useEffect(
+ () => {
+ dispatch(fetchDownloadClients());
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ return (
+
+
+ {translate('OverrideGrabModalTitle', { title })}
+
+
+
+
+ {downloadClients.length > 1 ? (
+ downloadClient.id === downloadClientId
+ )?.name ?? translate('Default')
+ }
+ onPress={onSelectDownloadClientPress}
+ />
+ }
+ />
+ ) : null}
+
+
+
+
+ {grabError}
+
+
+
+
+
+ {translate('GrabRelease')}
+
+
+
+
+
+
+ );
+}
+
+export default OverrideMatchModalContent;
diff --git a/frontend/src/Search/SearchFooter.js b/frontend/src/Search/SearchFooter.js
index 5e949fc6e..872328446 100644
--- a/frontend/src/Search/SearchFooter.js
+++ b/frontend/src/Search/SearchFooter.js
@@ -212,7 +212,11 @@ class SearchFooter extends Component {
name="searchQuery"
value={searchQuery}
buttons={
-
+
@@ -275,6 +279,7 @@ class SearchFooter extends Component {
}
+
@@ -314,7 +314,7 @@ class SearchIndex extends Component {
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
- isDisabled={hasNoIndexer}
+ isDisabled={hasNoSearchResults}
onFilterSelect={onFilterSelect}
/>
diff --git a/frontend/src/Search/SearchIndexConnector.js b/frontend/src/Search/SearchIndexConnector.js
index e3302e73c..78a9866b2 100644
--- a/frontend/src/Search/SearchIndexConnector.js
+++ b/frontend/src/Search/SearchIndexConnector.js
@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import withScrollPosition from 'Components/withScrollPosition';
import { bulkGrabReleases, cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions';
+import { fetchDownloadClients } from 'Store/Actions/Settings/downloadClients';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector';
import SearchIndex from './SearchIndex';
@@ -55,12 +56,20 @@ function createMapDispatchToProps(dispatch, props) {
dispatchClearReleases() {
dispatch(clearReleases());
+ },
+
+ dispatchFetchDownloadClients() {
+ dispatch(fetchDownloadClients());
}
};
}
class SearchIndexConnector extends Component {
+ componentDidMount() {
+ this.props.dispatchFetchDownloadClients();
+ }
+
componentWillUnmount() {
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
@@ -85,6 +94,7 @@ SearchIndexConnector.propTypes = {
onBulkGrabPress: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
+ dispatchFetchDownloadClients: PropTypes.func.isRequired,
items: PropTypes.arrayOf(PropTypes.object)
};
diff --git a/frontend/src/Search/Table/CategoryLabel.js b/frontend/src/Search/Table/CategoryLabel.js
deleted file mode 100644
index 5c076c521..000000000
--- a/frontend/src/Search/Table/CategoryLabel.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Label from 'Components/Label';
-import Tooltip from 'Components/Tooltip/Tooltip';
-import { kinds, tooltipPositions } from 'Helpers/Props';
-
-function CategoryLabel({ categories }) {
- const sortedCategories = categories.filter((cat) => cat.name !== undefined).sort((c) => c.id);
-
- if (categories?.length === 0) {
- return (
- Unknown}
- tooltip="Please report this issue to the GitHub as this shouldn't be happening"
- position={tooltipPositions.LEFT}
- />
- );
- }
-
- return (
-
- {
- sortedCategories.map((category) => {
- return (
-
- );
- })
- }
-
- );
-}
-
-CategoryLabel.defaultProps = {
- categories: []
-};
-
-CategoryLabel.propTypes = {
- categories: PropTypes.arrayOf(PropTypes.object).isRequired
-};
-
-export default CategoryLabel;
diff --git a/frontend/src/Search/Table/CategoryLabel.tsx b/frontend/src/Search/Table/CategoryLabel.tsx
new file mode 100644
index 000000000..4cfdeb1b2
--- /dev/null
+++ b/frontend/src/Search/Table/CategoryLabel.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import Label from 'Components/Label';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import { kinds, tooltipPositions } from 'Helpers/Props';
+import { IndexerCategory } from 'Indexer/Indexer';
+import translate from 'Utilities/String/translate';
+
+interface CategoryLabelProps {
+ categories: IndexerCategory[];
+}
+
+function CategoryLabel({ categories = [] }: CategoryLabelProps) {
+ if (categories?.length === 0) {
+ return (
+ {translate('Unknown')}}
+ tooltip="Please report this issue to the GitHub as this shouldn't be happening"
+ position={tooltipPositions.LEFT}
+ />
+ );
+ }
+
+ const sortedCategories = categories
+ .filter((cat) => cat.name !== undefined)
+ .sort((a, b) => a.id - b.id);
+
+ return (
+
+ {sortedCategories.map((category) => {
+ return ;
+ })}
+
+ );
+}
+
+export default CategoryLabel;
diff --git a/frontend/src/Search/Table/ReleaseLinks.css b/frontend/src/Search/Table/ReleaseLinks.css
new file mode 100644
index 000000000..d37a082a1
--- /dev/null
+++ b/frontend/src/Search/Table/ReleaseLinks.css
@@ -0,0 +1,13 @@
+.links {
+ margin: 0;
+}
+
+.link {
+ white-space: nowrap;
+}
+
+.linkLabel {
+ composes: label from '~Components/Label.css';
+
+ cursor: pointer;
+}
diff --git a/frontend/src/Search/Table/ReleaseLinks.css.d.ts b/frontend/src/Search/Table/ReleaseLinks.css.d.ts
new file mode 100644
index 000000000..9f91f93a4
--- /dev/null
+++ b/frontend/src/Search/Table/ReleaseLinks.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'link': string;
+ 'linkLabel': string;
+ 'links': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Search/Table/ReleaseLinks.tsx b/frontend/src/Search/Table/ReleaseLinks.tsx
new file mode 100644
index 000000000..38260bc21
--- /dev/null
+++ b/frontend/src/Search/Table/ReleaseLinks.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import { kinds, sizes } from 'Helpers/Props';
+import { IndexerCategory } from 'Indexer/Indexer';
+import styles from './ReleaseLinks.css';
+
+interface ReleaseLinksProps {
+ categories: IndexerCategory[];
+ imdbId?: string;
+ tmdbId?: number;
+ tvdbId?: number;
+ tvMazeId?: number;
+}
+
+function ReleaseLinks(props: ReleaseLinksProps) {
+ const { categories = [], imdbId, tmdbId, tvdbId, tvMazeId } = props;
+
+ const categoryNames = categories
+ .filter((item) => item.id < 100000)
+ .map((c) => c.name);
+
+ return (
+
+ {imdbId ? (
+
+
+
+ ) : null}
+
+ {tmdbId ? (
+
+
+
+ ) : null}
+
+ {tvdbId ? (
+
+
+
+ ) : null}
+
+ {tvMazeId ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export default ReleaseLinks;
diff --git a/frontend/src/Search/Table/SearchIndexItemConnector.js b/frontend/src/Search/Table/SearchIndexItemConnector.js
index 490214529..4cc7fb20c 100644
--- a/frontend/src/Search/Table/SearchIndexItemConnector.js
+++ b/frontend/src/Search/Table/SearchIndexItemConnector.js
@@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
-import { executeCommand } from 'Store/Actions/commandActions';
function createReleaseSelector() {
return createSelector(
@@ -37,10 +36,6 @@ function createMapStateToProps() {
);
}
-const mapDispatchToProps = {
- dispatchExecuteCommand: executeCommand
-};
-
class SearchIndexItemConnector extends Component {
//
@@ -71,4 +66,4 @@ SearchIndexItemConnector.propTypes = {
component: PropTypes.elementType.isRequired
};
-export default connect(createMapStateToProps, mapDispatchToProps)(SearchIndexItemConnector);
+export default connect(createMapStateToProps, null)(SearchIndexItemConnector);
diff --git a/frontend/src/Search/Table/SearchIndexRow.css b/frontend/src/Search/Table/SearchIndexRow.css
index 342092b81..b36ec4071 100644
--- a/frontend/src/Search/Table/SearchIndexRow.css
+++ b/frontend/src/Search/Table/SearchIndexRow.css
@@ -63,7 +63,37 @@
}
.externalLinks {
+ composes: button from '~Components/Link/IconButton.css';
+
+ color: var(--textColor);
+}
+
+.manualDownloadContent {
+ position: relative;
+ display: inline-block;
margin: 0 2px;
width: 22px;
+ height: 20.39px;
+ vertical-align: middle;
+ line-height: 20.39px;
+
+ &:hover {
+ color: var(--iconButtonHoverColor);
+ }
+}
+
+.interactiveIcon {
+ position: absolute;
+ top: 4px;
+ left: 0;
+ /* width: 100%; */
+ text-align: center;
+}
+
+.downloadIcon {
+ position: absolute;
+ top: 7px;
+ left: 8px;
+ /* width: 100%; */
text-align: center;
}
diff --git a/frontend/src/Search/Table/SearchIndexRow.css.d.ts b/frontend/src/Search/Table/SearchIndexRow.css.d.ts
index 6d625f58a..7552b96f9 100644
--- a/frontend/src/Search/Table/SearchIndexRow.css.d.ts
+++ b/frontend/src/Search/Table/SearchIndexRow.css.d.ts
@@ -6,12 +6,15 @@ interface CssExports {
'category': string;
'cell': string;
'checkInput': string;
+ 'downloadIcon': string;
'downloadLink': string;
'externalLinks': string;
'files': string;
'grabs': string;
'indexer': string;
'indexerFlags': string;
+ 'interactiveIcon': string;
+ 'manualDownloadContent': string;
'peers': string;
'protocol': string;
'size': string;
diff --git a/frontend/src/Search/Table/SearchIndexRow.js b/frontend/src/Search/Table/SearchIndexRow.js
deleted file mode 100644
index 67c267696..000000000
--- a/frontend/src/Search/Table/SearchIndexRow.js
+++ /dev/null
@@ -1,396 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Icon from 'Components/Icon';
-import IconButton from 'Components/Link/IconButton';
-import Link from 'Components/Link/Link';
-import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
-import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
-import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
-import Popover from 'Components/Tooltip/Popover';
-import { icons, kinds, tooltipPositions } from 'Helpers/Props';
-import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
-import formatDateTime from 'Utilities/Date/formatDateTime';
-import formatAge from 'Utilities/Number/formatAge';
-import formatBytes from 'Utilities/Number/formatBytes';
-import titleCase from 'Utilities/String/titleCase';
-import translate from 'Utilities/String/translate';
-import CategoryLabel from './CategoryLabel';
-import Peers from './Peers';
-import styles from './SearchIndexRow.css';
-
-function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
- if (isGrabbing) {
- return icons.SPINNER;
- } else if (isGrabbed) {
- return icons.DOWNLOADING;
- } else if (grabError) {
- return icons.DOWNLOADING;
- }
-
- return icons.DOWNLOAD;
-}
-
-function getDownloadKind(isGrabbed, grabError) {
- if (isGrabbed) {
- return kinds.SUCCESS;
- }
-
- if (grabError) {
- return kinds.DANGER;
- }
-
- return kinds.DEFAULT;
-}
-
-function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
- if (isGrabbing) {
- return '';
- } else if (isGrabbed) {
- return translate('AddedToDownloadClient');
- } else if (grabError) {
- return grabError;
- }
-
- return translate('AddToDownloadClient');
-}
-
-class SearchIndexRow extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isConfirmGrabModalOpen: false
- };
- }
-
- //
- // Listeners
-
- onGrabPress = () => {
- const {
- guid,
- indexerId,
- onGrabPress
- } = this.props;
-
- onGrabPress({
- guid,
- indexerId
- });
- };
-
- onSavePress = () => {
- const {
- downloadUrl,
- fileName,
- onSavePress
- } = this.props;
-
- onSavePress({
- downloadUrl,
- fileName
- });
- };
-
- //
- // Render
-
- render() {
- const {
- guid,
- protocol,
- downloadUrl,
- magnetUrl,
- categories,
- age,
- ageHours,
- ageMinutes,
- publishDate,
- title,
- infoUrl,
- indexer,
- size,
- files,
- grabs,
- seeders,
- leechers,
- indexerFlags,
- columns,
- isGrabbing,
- isGrabbed,
- grabError,
- longDateFormat,
- timeFormat,
- isSelected,
- onSelectedChange
- } = this.props;
-
- return (
- <>
- {
- columns.map((column) => {
- const {
- isVisible
- } = column;
-
- if (!isVisible) {
- return null;
- }
-
- if (column.name === 'select') {
- return (
-
- );
- }
-
- if (column.name === 'protocol') {
- return (
-
-
-
- );
- }
-
- if (column.name === 'age') {
- return (
-
- {formatAge(age, ageHours, ageMinutes)}
-
- );
- }
-
- if (column.name === 'sortTitle') {
- return (
-
-
-
- {title}
-
-
-
- );
- }
-
- if (column.name === 'indexer') {
- return (
-
- {indexer}
-
- );
- }
-
- if (column.name === 'size') {
- return (
-
- {formatBytes(size)}
-
- );
- }
-
- if (column.name === 'files') {
- return (
-
- {files}
-
- );
- }
-
- if (column.name === 'grabs') {
- return (
-
- {grabs}
-
- );
- }
-
- if (column.name === 'peers') {
- return (
-
- {
- protocol === 'torrent' &&
-
- }
-
- );
- }
-
- if (column.name === 'category') {
- return (
-
-
-
- );
- }
-
- if (column.name === 'indexerFlags') {
- return (
-
- {
- !!indexerFlags.length &&
-
- }
- title={translate('IndexerFlags')}
- body={
-
- {
- indexerFlags.map((flag, index) => {
- return (
- -
- {titleCase(flag)}
-
- );
- })
- }
-
- }
- position={tooltipPositions.LEFT}
- />
- }
-
- );
- }
-
- if (column.name === 'actions') {
- return (
-
-
-
- {
- downloadUrl ?
- :
- null
- }
-
- {
- magnetUrl ?
- :
- null
- }
-
- );
- }
-
- return null;
- })
- }
- >
- );
- }
-}
-
-SearchIndexRow.propTypes = {
- guid: PropTypes.string.isRequired,
- categories: PropTypes.arrayOf(PropTypes.object).isRequired,
- protocol: PropTypes.string.isRequired,
- age: PropTypes.number.isRequired,
- ageHours: PropTypes.number.isRequired,
- ageMinutes: PropTypes.number.isRequired,
- publishDate: PropTypes.string.isRequired,
- title: PropTypes.string.isRequired,
- fileName: PropTypes.string.isRequired,
- infoUrl: PropTypes.string.isRequired,
- downloadUrl: PropTypes.string,
- magnetUrl: PropTypes.string,
- indexerId: PropTypes.number.isRequired,
- indexer: PropTypes.string.isRequired,
- size: PropTypes.number.isRequired,
- files: PropTypes.number,
- grabs: PropTypes.number,
- seeders: PropTypes.number,
- leechers: PropTypes.number,
- indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired,
- columns: PropTypes.arrayOf(PropTypes.object).isRequired,
- onGrabPress: PropTypes.func.isRequired,
- onSavePress: PropTypes.func.isRequired,
- isGrabbing: PropTypes.bool.isRequired,
- isGrabbed: PropTypes.bool.isRequired,
- grabError: PropTypes.string,
- longDateFormat: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
- isSelected: PropTypes.bool,
- onSelectedChange: PropTypes.func.isRequired
-};
-
-SearchIndexRow.defaultProps = {
- isGrabbing: false,
- isGrabbed: false
-};
-
-export default SearchIndexRow;
diff --git a/frontend/src/Search/Table/SearchIndexRow.tsx b/frontend/src/Search/Table/SearchIndexRow.tsx
new file mode 100644
index 000000000..1136a7f64
--- /dev/null
+++ b/frontend/src/Search/Table/SearchIndexRow.tsx
@@ -0,0 +1,395 @@
+import React, { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
+import Column from 'Components/Table/Column';
+import Popover from 'Components/Tooltip/Popover';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
+import { IndexerCategory } from 'Indexer/Indexer';
+import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal';
+import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
+import { SelectStateInputProps } from 'typings/props';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import formatBytes from 'Utilities/Number/formatBytes';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import CategoryLabel from './CategoryLabel';
+import Peers from './Peers';
+import ReleaseLinks from './ReleaseLinks';
+import styles from './SearchIndexRow.css';
+
+function getDownloadIcon(
+ isGrabbing: boolean,
+ isGrabbed: boolean,
+ grabError?: string
+) {
+ if (isGrabbing) {
+ return icons.SPINNER;
+ } else if (isGrabbed) {
+ return icons.DOWNLOADING;
+ } else if (grabError) {
+ return icons.DOWNLOADING;
+ }
+
+ return icons.DOWNLOAD;
+}
+
+function getDownloadKind(isGrabbed: boolean, grabError?: string) {
+ if (isGrabbed) {
+ return kinds.SUCCESS;
+ }
+
+ if (grabError) {
+ return kinds.DANGER;
+ }
+
+ return kinds.DEFAULT;
+}
+
+function getDownloadTooltip(
+ isGrabbing: boolean,
+ isGrabbed: boolean,
+ grabError?: string
+) {
+ if (isGrabbing) {
+ return '';
+ } else if (isGrabbed) {
+ return translate('AddedToDownloadClient');
+ } else if (grabError) {
+ return grabError;
+ }
+
+ return translate('AddToDownloadClient');
+}
+
+interface SearchIndexRowProps {
+ guid: string;
+ protocol: DownloadProtocol;
+ age: number;
+ ageHours: number;
+ ageMinutes: number;
+ publishDate: string;
+ title: string;
+ fileName: string;
+ infoUrl: string;
+ downloadUrl?: string;
+ magnetUrl?: string;
+ indexerId: number;
+ indexer: string;
+ categories: IndexerCategory[];
+ size: number;
+ files?: number;
+ grabs?: number;
+ seeders?: number;
+ leechers?: number;
+ imdbId?: string;
+ tmdbId?: number;
+ tvdbId?: number;
+ tvMazeId?: number;
+ indexerFlags: string[];
+ isGrabbing: boolean;
+ isGrabbed: boolean;
+ grabError?: string;
+ longDateFormat: string;
+ timeFormat: string;
+ columns: Column[];
+ isSelected?: boolean;
+ onSelectedChange(result: SelectStateInputProps): void;
+ onGrabPress(...args: unknown[]): void;
+ onSavePress(...args: unknown[]): void;
+}
+
+function SearchIndexRow(props: SearchIndexRowProps) {
+ const {
+ guid,
+ indexerId,
+ protocol,
+ categories,
+ age,
+ ageHours,
+ ageMinutes,
+ publishDate,
+ title,
+ fileName,
+ infoUrl,
+ downloadUrl,
+ magnetUrl,
+ indexer,
+ size,
+ files,
+ grabs,
+ seeders,
+ leechers,
+ imdbId,
+ tmdbId,
+ tvdbId,
+ tvMazeId,
+ indexerFlags = [],
+ isGrabbing = false,
+ isGrabbed = false,
+ grabError,
+ longDateFormat,
+ timeFormat,
+ columns,
+ isSelected,
+ onSelectedChange,
+ onGrabPress,
+ onSavePress,
+ } = props;
+
+ const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
+
+ const { items: downloadClients } = useSelector(
+ createEnabledDownloadClientsSelector(protocol)
+ );
+
+ const onGrabPressWrapper = useCallback(() => {
+ onGrabPress({
+ guid,
+ indexerId,
+ });
+ }, [guid, indexerId, onGrabPress]);
+
+ const onSavePressWrapper = useCallback(() => {
+ onSavePress({
+ downloadUrl,
+ fileName,
+ });
+ }, [downloadUrl, fileName, onSavePress]);
+
+ const onOverridePress = useCallback(() => {
+ setIsOverrideModalOpen(true);
+ }, [setIsOverrideModalOpen]);
+
+ const onOverrideModalClose = useCallback(() => {
+ setIsOverrideModalOpen(false);
+ }, [setIsOverrideModalOpen]);
+
+ return (
+ <>
+ {columns.map((column) => {
+ const { name, isVisible } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'select') {
+ return (
+
+ );
+ }
+
+ if (name === 'protocol') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'age') {
+ return (
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+ );
+ }
+
+ if (name === 'sortTitle') {
+ return (
+
+
+ {title}
+
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'size') {
+ return (
+
+ {formatBytes(size)}
+
+ );
+ }
+
+ if (name === 'files') {
+ return (
+
+ {files}
+
+ );
+ }
+
+ if (name === 'grabs') {
+ return (
+
+ {grabs}
+
+ );
+ }
+
+ if (name === 'peers') {
+ return (
+
+ {protocol === 'torrent' && (
+
+ )}
+
+ );
+ }
+
+ if (name === 'category') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'indexerFlags') {
+ return (
+
+ {!!indexerFlags.length && (
+ }
+ title={translate('IndexerFlags')}
+ body={
+
+ {indexerFlags.map((flag, index) => {
+ return - {titleCase(flag)}
;
+ })}
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ )}
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ {downloadClients.length > 1 ? (
+
+
+
+
+
+
+
+ ) : null}
+
+ {downloadUrl ? (
+
+ ) : null}
+
+ {magnetUrl ? (
+
+ ) : null}
+
+ {imdbId || tmdbId || tvdbId || tvMazeId ? (
+
+ }
+ title={translate('Links')}
+ body={
+
+ }
+ position={tooltipPositions.TOP}
+ />
+ ) : null}
+
+ );
+ }
+
+ return null;
+ })}
+
+
+ >
+ );
+}
+
+export default SearchIndexRow;
diff --git a/frontend/src/Settings/Applications/ApplicationSettings.tsx b/frontend/src/Settings/Applications/ApplicationSettings.tsx
index c35d55e2d..7fc4b1d7b 100644
--- a/frontend/src/Settings/Applications/ApplicationSettings.tsx
+++ b/frontend/src/Settings/Applications/ApplicationSettings.tsx
@@ -1,4 +1,4 @@
-import React, { Fragment, useCallback, useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { APP_INDEXER_SYNC } from 'Commands/commandNames';
@@ -56,7 +56,7 @@ function ApplicationSettings() {
// @ts-ignore
showSave={false}
additionalButtons={
-
+ <>
-
+ >
}
/>
diff --git a/frontend/src/Settings/Applications/Applications/Application.js b/frontend/src/Settings/Applications/Applications/Application.js
index 610cc344d..086d39ee1 100644
--- a/frontend/src/Settings/Applications/Applications/Application.js
+++ b/frontend/src/Settings/Applications/Applications/Application.js
@@ -57,6 +57,7 @@ class Application extends Component {
const {
id,
name,
+ enable,
syncLevel,
fields,
tags,
@@ -77,7 +78,7 @@ class Application extends Component {
{
- applicationUrl ?
+ enable && applicationUrl ?