@@ -56,7 +56,9 @@ function ProgressBar(props) {
styles[kind],
enableColorImpairedMode && 'colorImpaired'
)}
- aria-valuenow={progress}
+ role="meter"
+ aria-label={`Progress Bar at ${progress.toFixed(0)}%`}
+ aria-valuenow={progress.toFixed(0)}
aria-valuemin="0"
aria-valuemax="100"
style={{ width: progressPercent }}
@@ -65,7 +67,7 @@ function ProgressBar(props) {
{
showText ?
void;
+ onScroll?: (payload: OnScroll) => void;
}
const Scroller = forwardRef(
- (props: ScrollerProps, ref: React.MutableRefObject
) => {
+ (props: ScrollerProps, ref: ForwardedRef) => {
const {
className,
autoFocus = false,
@@ -30,7 +42,7 @@ const Scroller = forwardRef(
} = props;
const internalRef = useRef();
- const currentRef = ref ?? internalRef;
+ const currentRef = (ref as MutableRefObject) ?? internalRef;
useEffect(
() => {
diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js
index 3c10c2754..d39c05e10 100644
--- a/frontend/src/Components/SignalRConnector.js
+++ b/frontend/src/Components/SignalRConnector.js
@@ -54,7 +54,7 @@ function Logger(minimumLogLevel) {
}
Logger.prototype.cleanse = function(message) {
- const apikey = new RegExp(`access_token=${window.Prowlarr.apiKey}`, 'g');
+ const apikey = new RegExp(`access_token=${encodeURIComponent(window.Prowlarr.apiKey)}`, 'g');
return message.replace(apikey, 'access_token=(removed)');
};
@@ -98,7 +98,7 @@ class SignalRConnector extends Component {
this.connection = new signalR.HubConnectionBuilder()
.configureLogging(new Logger(signalR.LogLevel.Information))
- .withUrl(`${url}?access_token=${window.Prowlarr.apiKey}`)
+ .withUrl(`${url}?access_token=${encodeURIComponent(window.Prowlarr.apiKey)}`)
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
if (retryContext.elapsedMilliseconds > 180000) {
@@ -141,6 +141,16 @@ class SignalRConnector extends Component {
console.error(`signalR: Unable to find handler for ${name}`);
};
+ handleApplications = ({ action, resource }) => {
+ const section = 'settings.applications';
+
+ if (action === 'created' || action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: resource.id });
+ }
+ };
+
handleCommand = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchCommands();
@@ -150,8 +160,8 @@ class SignalRConnector extends Component {
const resource = body.resource;
const status = resource.status;
- // Both sucessful and failed commands need to be
- // completed, otherwise they spin until they timeout.
+ // Both successful and failed commands need to be
+ // completed, otherwise they spin until they time out.
if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource);
@@ -160,6 +170,16 @@ class SignalRConnector extends Component {
}
};
+ handleDownloadclient = ({ action, resource }) => {
+ const section = 'settings.downloadClients';
+
+ if (action === 'created' || action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: resource.id });
+ }
+ };
+
handleHealth = () => {
this.props.dispatchFetchHealth();
};
@@ -168,14 +188,33 @@ class SignalRConnector extends Component {
this.props.dispatchFetchIndexerStatus();
};
- handleIndexer = (body) => {
- const action = body.action;
+ handleIndexer = ({ action, resource }) => {
const section = 'indexers';
- if (action === 'updated') {
- this.props.dispatchUpdateItem({ section, ...body.resource });
+ if (action === 'created' || action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
- this.props.dispatchRemoveItem({ section, id: body.resource.id });
+ this.props.dispatchRemoveItem({ section, id: resource.id });
+ }
+ };
+
+ handleIndexerproxy = ({ action, resource }) => {
+ const section = 'settings.indexerProxies';
+
+ if (action === 'created' || action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: resource.id });
+ }
+ };
+
+ handleNotification = ({ action, resource }) => {
+ const section = 'settings.notifications';
+
+ if (action === 'created' || action === 'updated') {
+ this.props.dispatchUpdateItem({ section, ...resource });
+ } else if (action === 'deleted') {
+ this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js
index 207b97752..4bf94cf57 100644
--- a/frontend/src/Components/Table/Cells/RelativeDateCell.js
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js
@@ -1,58 +1,66 @@
import PropTypes from 'prop-types';
-import React, { PureComponent } from 'react';
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import TableRowCell from './TableRowCell';
import styles from './RelativeDateCell.css';
-class RelativeDateCell extends PureComponent {
+function createRelativeDateCellSelector() {
+ return createSelector(createUISettingsSelector(), (uiSettings) => {
+ return {
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ });
+}
+function RelativeDateCell(props) {
//
// Render
- render() {
- const {
- className,
- date,
- includeSeconds,
- showRelativeDates,
- shortDateFormat,
- longDateFormat,
- timeFormat,
- component: Component,
- dispatch,
- ...otherProps
- } = this.props;
+ const {
+ className,
+ date,
+ includeSeconds,
+ component: Component,
+ dispatch,
+ ...otherProps
+ } = props;
- if (!date) {
- return (
-
- );
- }
+ const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
+ useSelector(createRelativeDateCellSelector());
- return (
-
- {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })}
-
- );
+ if (!date) {
+ return ;
}
+
+ return (
+
+ {getRelativeDate(date, shortDateFormat, showRelativeDates, {
+ timeFormat,
+ includeSeconds,
+ timeForToday: true
+ })}
+
+ );
}
RelativeDateCell.propTypes = {
className: PropTypes.string.isRequired,
date: PropTypes.string,
includeSeconds: PropTypes.bool.isRequired,
- showRelativeDates: PropTypes.bool.isRequired,
- shortDateFormat: PropTypes.string.isRequired,
- longDateFormat: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
component: PropTypes.elementType,
dispatch: PropTypes.func
};
diff --git a/frontend/src/Components/Form/PasswordInput.css.d.ts b/frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
similarity index 89%
rename from frontend/src/Components/Form/PasswordInput.css.d.ts
rename to frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
index 774807ef4..c748f6f97 100644
--- a/frontend/src/Components/Form/PasswordInput.css.d.ts
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
@@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
- 'input': string;
+ 'cell': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js
deleted file mode 100644
index ff50d3bc9..000000000
--- a/frontend/src/Components/Table/Cells/TableRowCellButton.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Link from 'Components/Link/Link';
-import TableRowCell from './TableRowCell';
-import styles from './TableRowCellButton.css';
-
-function TableRowCellButton({ className, ...otherProps }) {
- return (
-
- );
-}
-
-TableRowCellButton.propTypes = {
- className: PropTypes.string.isRequired
-};
-
-TableRowCellButton.defaultProps = {
- className: styles.cell
-};
-
-export default TableRowCellButton;
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.tsx b/frontend/src/Components/Table/Cells/TableRowCellButton.tsx
new file mode 100644
index 000000000..c80a3d626
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.tsx
@@ -0,0 +1,19 @@
+import React, { ReactNode } from 'react';
+import Link, { LinkProps } from 'Components/Link/Link';
+import TableRowCell from './TableRowCell';
+import styles from './TableRowCellButton.css';
+
+interface TableRowCellButtonProps extends LinkProps {
+ className?: string;
+ children: ReactNode;
+}
+
+function TableRowCellButton(props: TableRowCellButtonProps) {
+ const { className = styles.cell, ...otherProps } = props;
+
+ return (
+
+ );
+}
+
+export default TableRowCellButton;
diff --git a/frontend/src/Indexer/Stats/Stats.css.d.ts b/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
similarity index 74%
rename from frontend/src/Indexer/Stats/Stats.css.d.ts
rename to frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
index ce2364202..b6aee3c85 100644
--- a/frontend/src/Indexer/Stats/Stats.css.d.ts
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
@@ -1,8 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
- 'fullWidthChart': string;
- 'halfWidthChart': string;
+ 'input': string;
+ 'selectCell': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts
index f9ff7287c..24674c3fc 100644
--- a/frontend/src/Components/Table/Column.ts
+++ b/frontend/src/Components/Table/Column.ts
@@ -1,8 +1,14 @@
+import React from 'react';
+
+type PropertyFunction = () => T;
+
+// TODO: Convert to generic so `name` can be a type
interface Column {
name: string;
- label: string;
- columnLabel: string;
- isSortable: boolean;
+ label: string | PropertyFunction | React.ReactNode;
+ className?: string;
+ columnLabel?: string;
+ isSortable?: boolean;
isVisible: boolean;
isModifiable?: boolean;
}
diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js
index c41fc982a..8afbf9ea0 100644
--- a/frontend/src/Components/Table/Table.js
+++ b/frontend/src/Components/Table/Table.js
@@ -107,7 +107,7 @@ function Table(props) {
{...getTableHeaderCellProps(otherProps)}
{...column}
>
- {column.label}
+ {typeof column.label === 'function' ? column.label() : column.label}
);
})
@@ -121,6 +121,7 @@ function Table(props) {
}
Table.propTypes = {
+ ...TableHeaderCell.props,
className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired,
diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js
index 21766978b..b0ed5c571 100644
--- a/frontend/src/Components/Table/TableHeaderCell.js
+++ b/frontend/src/Components/Table/TableHeaderCell.js
@@ -30,6 +30,7 @@ class TableHeaderCell extends Component {
const {
className,
name,
+ label,
columnLabel,
isSortable,
isVisible,
@@ -53,7 +54,8 @@ class TableHeaderCell extends Component {
{...otherProps}
component="th"
className={className}
- title={columnLabel}
+ label={typeof label === 'function' ? label() : label}
+ title={typeof columnLabel === 'function' ? columnLabel() : columnLabel}
onPress={this.onPress}
>
{children}
@@ -77,7 +79,8 @@ class TableHeaderCell extends Component {
TableHeaderCell.propTypes = {
className: PropTypes.string,
name: PropTypes.string.isRequired,
- columnLabel: PropTypes.string,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.func, PropTypes.node]),
+ columnLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
isSortable: PropTypes.bool,
isVisible: PropTypes.bool,
isModifiable: PropTypes.bool,
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
index 2d91c7c63..402ef5ae1 100644
--- a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
@@ -35,7 +35,7 @@ function TableOptionsColumn(props) {
isDisabled={isModifiable === false}
onChange={onVisibleChange}
/>
- {label}
+ {typeof label === 'function' ? label() : label}
{
@@ -56,7 +56,7 @@ function TableOptionsColumn(props) {
TableOptionsColumn.propTypes = {
name: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
isVisible: PropTypes.bool.isRequired,
isModifiable: PropTypes.bool.isRequired,
index: PropTypes.number.isRequired,
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
index 100559660..77d18463f 100644
--- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
@@ -112,7 +112,7 @@ class TableOptionsColumnDragSource extends Component {
@@ -180,16 +187,19 @@ VirtualTable.propTypes = {
className: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
scrollIndex: PropTypes.number,
+ scrollTop: PropTypes.number,
scroller: PropTypes.instanceOf(Element).isRequired,
focusScroller: PropTypes.bool.isRequired,
header: PropTypes.node.isRequired,
headerHeight: PropTypes.number.isRequired,
- rowRenderer: PropTypes.func.isRequired
+ rowRenderer: PropTypes.func.isRequired,
+ rowHeight: PropTypes.number.isRequired
};
VirtualTable.defaultProps = {
className: styles.tableContainer,
headerHeight: 38,
+ rowHeight: ROW_HEIGHT,
focusScroller: true
};
diff --git a/frontend/src/Components/Table/usePaging.ts b/frontend/src/Components/Table/usePaging.ts
new file mode 100644
index 000000000..dfebb2355
--- /dev/null
+++ b/frontend/src/Components/Table/usePaging.ts
@@ -0,0 +1,54 @@
+import { useCallback, useMemo } from 'react';
+import { useDispatch } from 'react-redux';
+
+interface PagingOptions {
+ page: number;
+ totalPages: number;
+ gotoPage: ({ page }: { page: number }) => void;
+}
+
+function usePaging(options: PagingOptions) {
+ const { page, totalPages, gotoPage } = options;
+ const dispatch = useDispatch();
+
+ const handleFirstPagePress = useCallback(() => {
+ dispatch(gotoPage({ page: 1 }));
+ }, [dispatch, gotoPage]);
+
+ const handlePreviousPagePress = useCallback(() => {
+ dispatch(gotoPage({ page: Math.max(page - 1, 1) }));
+ }, [page, dispatch, gotoPage]);
+
+ const handleNextPagePress = useCallback(() => {
+ dispatch(gotoPage({ page: Math.min(page + 1, totalPages) }));
+ }, [page, totalPages, dispatch, gotoPage]);
+
+ const handleLastPagePress = useCallback(() => {
+ dispatch(gotoPage({ page: totalPages }));
+ }, [totalPages, dispatch, gotoPage]);
+
+ const handlePageSelect = useCallback(
+ (page: number) => {
+ dispatch(gotoPage({ page }));
+ },
+ [dispatch, gotoPage]
+ );
+
+ return useMemo(() => {
+ return {
+ handleFirstPagePress,
+ handlePreviousPagePress,
+ handleNextPagePress,
+ handleLastPagePress,
+ handlePageSelect,
+ };
+ }, [
+ handleFirstPagePress,
+ handlePreviousPagePress,
+ handleNextPagePress,
+ handleLastPagePress,
+ handlePageSelect,
+ ]);
+}
+
+export default usePaging;
diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js
index f4d4e2af4..fe700b8fe 100644
--- a/frontend/src/Components/TagList.js
+++ b/frontend/src/Components/TagList.js
@@ -1,14 +1,15 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
+import sortByProp from 'Utilities/Array/sortByProp';
import Label from './Label';
import styles from './TagList.css';
function TagList({ tags, tagList }) {
const sortedTags = tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
- .filter((t) => t !== undefined)
- .sort((a, b) => a.label.localeCompare(b.label));
+ .filter((tag) => !!tag)
+ .sort(sortByProp('label'));
return (
diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js
index 713f2bff4..8513a65eb 100644
--- a/frontend/src/Components/keyboardShortcuts.js
+++ b/frontend/src/Components/keyboardShortcuts.js
@@ -6,37 +6,51 @@ import translate from 'Utilities/String/translate';
export const shortcuts = {
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
key: '?',
- name: translate('OpenThisModal')
+ get name() {
+ return translate('OpenThisModal');
+ }
},
CLOSE_MODAL: {
key: 'Esc',
- name: translate('CloseCurrentModal')
+ get name() {
+ return translate('CloseCurrentModal');
+ }
},
ACCEPT_CONFIRM_MODAL: {
key: 'Enter',
- name: translate('AcceptConfirmationModal')
+ get name() {
+ return translate('AcceptConfirmationModal');
+ }
},
MOVIE_SEARCH_INPUT: {
key: 's',
- name: translate('FocusSearchBox')
+ get name() {
+ return translate('FocusSearchBox');
+ }
},
SAVE_SETTINGS: {
key: 'mod+s',
- name: translate('SaveSettings')
+ get name() {
+ return translate('SaveSettings');
+ }
},
SCROLL_TOP: {
key: 'mod+home',
- name: translate('MovieIndexScrollTop')
+ get name() {
+ return translate('MovieIndexScrollTop');
+ }
},
SCROLL_BOTTOM: {
key: 'mod+end',
- name: translate('MovieIndexScrollBottom')
+ get name() {
+ return translate('MovieIndexScrollBottom');
+ }
}
};
@@ -67,8 +81,10 @@ function keyboardShortcuts(WrappedComponent) {
};
unbindShortcut = (key) => {
- delete this._mousetrapBindings[key];
- this._mousetrap.unbind(key);
+ if (this._mousetrap != null) {
+ delete this._mousetrapBindings[key];
+ this._mousetrap.unbind(key);
+ }
};
unbindAllShortcuts = () => {
diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx
index ec13c6ab8..f688a6253 100644
--- a/frontend/src/Components/withScrollPosition.tsx
+++ b/frontend/src/Components/withScrollPosition.tsx
@@ -1,24 +1,30 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import { RouteComponentProps } from 'react-router-dom';
import scrollPositions from 'Store/scrollPositions';
-function withScrollPosition(WrappedComponent, scrollPositionKey) {
- function ScrollPosition(props) {
+interface WrappedComponentProps {
+ initialScrollTop: number;
+}
+
+interface ScrollPositionProps {
+ history: RouteComponentProps['history'];
+ location: RouteComponentProps['location'];
+ match: RouteComponentProps['match'];
+}
+
+function withScrollPosition(
+ WrappedComponent: React.FC
,
+ scrollPositionKey: string
+) {
+ function ScrollPosition(props: ScrollPositionProps) {
const { history } = props;
const initialScrollTop =
- history.action === 'POP' ||
- (history.location.state && history.location.state.restoreScrollPosition)
- ? scrollPositions[scrollPositionKey]
- : 0;
+ history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0;
return ;
}
- ScrollPosition.propTypes = {
- history: PropTypes.object.isRequired,
- };
-
return ScrollPosition;
}
diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css
index bf31501dd..e0f1bf5dc 100644
--- a/frontend/src/Content/Fonts/fonts.css
+++ b/frontend/src/Content/Fonts/fonts.css
@@ -25,14 +25,3 @@
font-family: 'Ubuntu Mono';
src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
}
-
-/*
- * text-security-disc
- */
-
-@font-face {
- font-weight: normal;
- font-style: normal;
- font-family: 'text-security-disc';
- src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype');
-}
diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf
deleted file mode 100644
index 86038dba8..000000000
Binary files a/frontend/src/Content/Fonts/text-security-disc.ttf and /dev/null differ
diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff
deleted file mode 100644
index bc4cc324b..000000000
Binary files a/frontend/src/Content/Fonts/text-security-disc.woff and /dev/null differ
diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json
index d14732f60..f53279dd3 100644
--- a/frontend/src/Content/Images/Icons/manifest.json
+++ b/frontend/src/Content/Images/Icons/manifest.json
@@ -1,18 +1,19 @@
{
- "name": "",
+ "name": "Prowlarr",
"icons": [
{
- "src": "/Content/Images/Icons/android-chrome-192x192.png",
+ "src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
- "src": "/Content/Images/Icons/android-chrome-512x512.png",
+ "src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
+ "start_url": "../../../../",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
-}
\ No newline at end of file
+}
diff --git a/frontend/src/DownloadClient/DownloadProtocol.ts b/frontend/src/DownloadClient/DownloadProtocol.ts
new file mode 100644
index 000000000..417db8178
--- /dev/null
+++ b/frontend/src/DownloadClient/DownloadProtocol.ts
@@ -0,0 +1,3 @@
+type DownloadProtocol = 'usenet' | 'torrent' | 'unknown';
+
+export default DownloadProtocol;
diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js
index 920c59a31..17a04e403 100644
--- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js
+++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js
@@ -11,7 +11,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
-import { authenticationMethodOptions, authenticationRequiredOptions, authenticationRequiredWarning } from 'Settings/General/SecuritySettings';
+import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings';
import translate from 'Utilities/String/translate';
import styles from './AuthenticationRequiredModalContent.css';
@@ -34,7 +34,8 @@ function AuthenticationRequiredModalContent(props) {
authenticationMethod,
authenticationRequired,
username,
- password
+ password,
+ passwordConfirmation
} = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
@@ -63,71 +64,75 @@ function AuthenticationRequiredModalContent(props) {
className={styles.authRequiredAlert}
kind={kinds.WARNING}
>
- {authenticationRequiredWarning}
+ {translate('AuthenticationRequiredWarning')}
{
isPopulated && !error ?
- {translate('Authentication')}
+ {translate('AuthenticationMethod')}
- {
- authenticationEnabled ?
-
- {translate('AuthenticationRequired')}
+
+ {translate('AuthenticationRequired')}
-
- :
- null
- }
+
+
- {
- authenticationEnabled ?
-
- {translate('Username')}
+
+ {translate('Username')}
-
- :
- null
- }
+
+
- {
- authenticationEnabled ?
-
- {translate('Password')}
+
+ {translate('Password')}
-
- :
- null
- }
+
+
+
+
+ {translate('PasswordConfirmation')}
+
+
+
:
null
}
diff --git a/frontend/src/Helpers/Hooks/useCurrentPage.ts b/frontend/src/Helpers/Hooks/useCurrentPage.ts
new file mode 100644
index 000000000..3caf66df2
--- /dev/null
+++ b/frontend/src/Helpers/Hooks/useCurrentPage.ts
@@ -0,0 +1,9 @@
+import { useHistory } from 'react-router-dom';
+
+function useCurrentPage() {
+ const history = useHistory();
+
+ return history.action === 'POP';
+}
+
+export default useCurrentPage;
diff --git a/frontend/src/Helpers/Hooks/useModalOpenState.ts b/frontend/src/Helpers/Hooks/useModalOpenState.ts
new file mode 100644
index 000000000..24cffb2f1
--- /dev/null
+++ b/frontend/src/Helpers/Hooks/useModalOpenState.ts
@@ -0,0 +1,17 @@
+import { useCallback, useState } from 'react';
+
+export default function useModalOpenState(
+ initialState: boolean
+): [boolean, () => void, () => void] {
+ const [isOpen, setIsOpen] = useState(initialState);
+
+ const setModalOpen = useCallback(() => {
+ setIsOpen(true);
+ }, [setIsOpen]);
+
+ const setModalClosed = useCallback(() => {
+ setIsOpen(false);
+ }, [setIsOpen]);
+
+ return [isOpen, setModalOpen, setModalClosed];
+}
diff --git a/frontend/src/Helpers/Hooks/usePrevious.tsx b/frontend/src/Helpers/Hooks/usePrevious.tsx
new file mode 100644
index 000000000..b594e2632
--- /dev/null
+++ b/frontend/src/Helpers/Hooks/usePrevious.tsx
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+
+export default function usePrevious(value: T): T | undefined {
+ const ref = useRef();
+
+ useEffect(() => {
+ ref.current = value;
+ }, [value]);
+
+ return ref.current;
+}
diff --git a/frontend/src/Helpers/Hooks/useSelectState.tsx b/frontend/src/Helpers/Hooks/useSelectState.tsx
new file mode 100644
index 000000000..8fb96e42a
--- /dev/null
+++ b/frontend/src/Helpers/Hooks/useSelectState.tsx
@@ -0,0 +1,113 @@
+import { cloneDeep } from 'lodash';
+import { useReducer } from 'react';
+import ModelBase from 'App/ModelBase';
+import areAllSelected from 'Utilities/Table/areAllSelected';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+
+export type SelectedState = Record;
+
+export interface SelectState {
+ selectedState: SelectedState;
+ lastToggled: number | null;
+ allSelected: boolean;
+ allUnselected: boolean;
+}
+
+export type SelectAction =
+ | { type: 'reset' }
+ | { type: 'selectAll'; items: ModelBase[] }
+ | { type: 'unselectAll'; items: ModelBase[] }
+ | {
+ type: 'toggleSelected';
+ id: number;
+ isSelected: boolean;
+ shiftKey: boolean;
+ items: ModelBase[];
+ }
+ | {
+ type: 'removeItem';
+ id: number;
+ }
+ | {
+ type: 'updateItems';
+ items: ModelBase[];
+ };
+
+export type Dispatch = (action: SelectAction) => void;
+
+const initialState = {
+ selectedState: {},
+ lastToggled: null,
+ allSelected: false,
+ allUnselected: true,
+ items: [],
+};
+
+function getSelectedState(items: ModelBase[], existingState: SelectedState) {
+ return items.reduce((acc: SelectedState, item) => {
+ const id = item.id;
+
+ acc[id] = existingState[id] ?? false;
+
+ return acc;
+ }, {});
+}
+
+function selectReducer(state: SelectState, action: SelectAction): SelectState {
+ const { selectedState } = state;
+
+ switch (action.type) {
+ case 'reset': {
+ return cloneDeep(initialState);
+ }
+ case 'selectAll': {
+ return {
+ ...selectAll(selectedState, true),
+ };
+ }
+ case 'unselectAll': {
+ return {
+ ...selectAll(selectedState, false),
+ };
+ }
+ case 'toggleSelected': {
+ const result = {
+ ...toggleSelected(
+ state,
+ action.items,
+ action.id,
+ action.isSelected,
+ action.shiftKey
+ ),
+ };
+
+ return result;
+ }
+ case 'updateItems': {
+ const nextSelectedState = getSelectedState(action.items, selectedState);
+
+ return {
+ ...state,
+ ...areAllSelected(nextSelectedState),
+ selectedState: nextSelectedState,
+ };
+ }
+ default: {
+ throw new Error(`Unhandled action type: ${action.type}`);
+ }
+ }
+}
+
+export default function useSelectState(): [SelectState, Dispatch] {
+ const selectedState = getSelectedState([], {});
+
+ const [state, dispatch] = useReducer(selectReducer, {
+ selectedState,
+ lastToggled: null,
+ allSelected: false,
+ allUnselected: true,
+ });
+
+ return [state, dispatch];
+}
diff --git a/frontend/src/Helpers/Props/TooltipPosition.ts b/frontend/src/Helpers/Props/TooltipPosition.ts
new file mode 100644
index 000000000..885c73470
--- /dev/null
+++ b/frontend/src/Helpers/Props/TooltipPosition.ts
@@ -0,0 +1,3 @@
+type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
+
+export default TooltipPosition;
diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.ts
similarity index 100%
rename from frontend/src/Helpers/Props/align.js
rename to frontend/src/Helpers/Props/align.ts
diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js
index 776ba2afc..c0806fabc 100644
--- a/frontend/src/Helpers/Props/filterBuilderTypes.js
+++ b/frontend/src/Helpers/Props/filterBuilderTypes.js
@@ -1,14 +1,18 @@
import * as filterTypes from './filterTypes';
export const ARRAY = 'array';
+export const CONTAINS = 'contains';
export const DATE = 'date';
+export const EQUAL = 'equal';
export const EXACT = 'exact';
export const NUMBER = 'number';
export const STRING = 'string';
export const all = [
ARRAY,
+ CONTAINS,
DATE,
+ EQUAL,
EXACT,
NUMBER,
STRING
@@ -20,6 +24,10 @@ export const possibleFilterTypes = {
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
],
+ [CONTAINS]: [
+ { key: filterTypes.CONTAINS, value: 'contains' }
+ ],
+
[DATE]: [
{ key: filterTypes.LESS_THAN, value: 'is before' },
{ key: filterTypes.GREATER_THAN, value: 'is after' },
@@ -29,6 +37,10 @@ export const possibleFilterTypes = {
{ key: filterTypes.NOT_IN_NEXT, value: 'not in the next' }
],
+ [EQUAL]: [
+ { key: filterTypes.EQUAL, value: 'is' }
+ ],
+
[EXACT]: [
{ key: filterTypes.EQUAL, value: 'is' },
{ key: filterTypes.NOT_EQUAL, value: 'is not' }
@@ -47,6 +59,10 @@ export const possibleFilterTypes = {
{ key: filterTypes.CONTAINS, value: 'contains' },
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' },
{ key: filterTypes.EQUAL, value: 'equal' },
- { key: filterTypes.NOT_EQUAL, value: 'not equal' }
+ { key: filterTypes.NOT_EQUAL, value: 'not equal' },
+ { key: filterTypes.STARTS_WITH, value: 'starts with' },
+ { key: filterTypes.NOT_STARTS_WITH, value: 'does not start with' },
+ { key: filterTypes.ENDS_WITH, value: 'ends with' },
+ { key: filterTypes.NOT_ENDS_WITH, value: 'does not end with' }
]
};
diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
index 7fed535f2..73ef41956 100644
--- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js
+++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
@@ -2,9 +2,10 @@ export const BOOL = 'bool';
export const BYTES = 'bytes';
export const DATE = 'date';
export const DEFAULT = 'default';
+export const HISTORY_EVENT_TYPE = 'historyEventType';
export const INDEXER = 'indexer';
export const PROTOCOL = 'protocol';
export const PRIVACY = 'privacy';
export const APP_PROFILE = 'appProfile';
-export const MOVIE_STATUS = 'movieStatus';
+export const CATEGORY = 'category';
export const TAG = 'tag';
diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js
index a3ea11956..d07059c02 100644
--- a/frontend/src/Helpers/Props/filterTypePredicates.js
+++ b/frontend/src/Helpers/Props/filterTypePredicates.js
@@ -39,6 +39,22 @@ const filterTypePredicates = {
[filterTypes.NOT_EQUAL]: function(itemValue, filterValue) {
return itemValue !== filterValue;
+ },
+
+ [filterTypes.STARTS_WITH]: function(itemValue, filterValue) {
+ return itemValue.toLowerCase().startsWith(filterValue.toLowerCase());
+ },
+
+ [filterTypes.NOT_STARTS_WITH]: function(itemValue, filterValue) {
+ return !itemValue.toLowerCase().startsWith(filterValue.toLowerCase());
+ },
+
+ [filterTypes.ENDS_WITH]: function(itemValue, filterValue) {
+ return itemValue.toLowerCase().endsWith(filterValue.toLowerCase());
+ },
+
+ [filterTypes.NOT_ENDS_WITH]: function(itemValue, filterValue) {
+ return !itemValue.toLowerCase().endsWith(filterValue.toLowerCase());
}
};
diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js
index 993e8df57..239a4e7e9 100644
--- a/frontend/src/Helpers/Props/filterTypes.js
+++ b/frontend/src/Helpers/Props/filterTypes.js
@@ -10,6 +10,10 @@ export const LESS_THAN = 'lessThan';
export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual';
export const NOT_CONTAINS = 'notContains';
export const NOT_EQUAL = 'notEqual';
+export const STARTS_WITH = 'startsWith';
+export const NOT_STARTS_WITH = 'notStartsWith';
+export const ENDS_WITH = 'endsWith';
+export const NOT_ENDS_WITH = 'notEndsWith';
export const all = [
CONTAINS,
@@ -23,5 +27,9 @@ export const all = [
IN_LAST,
NOT_IN_LAST,
IN_NEXT,
- NOT_IN_NEXT
+ NOT_IN_NEXT,
+ STARTS_WITH,
+ NOT_STARTS_WITH,
+ ENDS_WITH,
+ NOT_ENDS_WITH
];
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
index 00e2c1aa0..773748996 100644
--- a/frontend/src/Helpers/Props/icons.js
+++ b/frontend/src/Helpers/Props/icons.js
@@ -43,6 +43,7 @@ import {
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle,
+ faCircleDown as fasCircleDown,
faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog,
@@ -72,8 +73,10 @@ import {
faLanguage as fasLanguage,
faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt,
+ faListCheck as fasListCheck,
faLocationArrow as fasLocationArrow,
faLock as fasLock,
+ faMagnet as fasMagnet,
faMedkit as fasMedkit,
faMinus as fasMinus,
faMusic as fasMusic,
@@ -139,6 +142,7 @@ export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle;
+export const CIRCLE_DOWN = fasCircleDown;
export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt;
export const CLIPBOARD = fasCopy;
@@ -180,6 +184,8 @@ export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard;
export const LOCK = fasLock;
export const LOGOUT = fasSignOutAlt;
+export const MAGNET = fasMagnet;
+export const MANAGE = fasListCheck;
export const MEDIA_INFO = farFileInvoice;
export const MISSING = fasExclamationTriangle;
export const MONITORED = fasBookmark;
diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js
index 7a11bb0c7..f9cd58e6d 100644
--- a/frontend/src/Helpers/Props/inputTypes.js
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -1,6 +1,5 @@
export const AUTO_COMPLETE = 'autoComplete';
export const APP_PROFILE_SELECT = 'appProfileSelect';
-export const AVAILABILITY_SELECT = 'availabilitySelect';
export const CAPTCHA = 'captcha';
export const CARDIGANNCAPTCHA = 'cardigannCaptcha';
export const CHECK = 'check';
@@ -9,6 +8,8 @@ export const KEY_VALUE_LIST = 'keyValueList';
export const INFO = 'info';
export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect';
export const CATEGORY_SELECT = 'newznabCategorySelect';
+export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect';
+export const FLOAT = 'float';
export const NUMBER = 'number';
export const OAUTH = 'oauth';
export const PASSWORD = 'password';
@@ -25,7 +26,6 @@ export const TAG_SELECT = 'tagSelect';
export const all = [
AUTO_COMPLETE,
APP_PROFILE_SELECT,
- AVAILABILITY_SELECT,
CAPTCHA,
CARDIGANNCAPTCHA,
CHECK,
@@ -34,6 +34,7 @@ export const all = [
INFO,
MOVIE_MONITORED_SELECT,
CATEGORY_SELECT,
+ FLOAT,
NUMBER,
OAUTH,
PASSWORD,
diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.ts
similarity index 72%
rename from frontend/src/Helpers/Props/kinds.js
rename to frontend/src/Helpers/Props/kinds.ts
index b0f5ac87f..7ce606716 100644
--- a/frontend/src/Helpers/Props/kinds.js
+++ b/frontend/src/Helpers/Props/kinds.ts
@@ -7,7 +7,6 @@ export const PRIMARY = 'primary';
export const PURPLE = 'purple';
export const SUCCESS = 'success';
export const WARNING = 'warning';
-export const QUEUE = 'queue';
export const all = [
DANGER,
@@ -19,5 +18,15 @@ export const all = [
PURPLE,
SUCCESS,
WARNING,
- QUEUE
-];
+] as const;
+
+export type Kind =
+ | 'danger'
+ | 'default'
+ | 'disabled'
+ | 'info'
+ | 'inverse'
+ | 'primary'
+ | 'purple'
+ | 'success'
+ | 'warning';
diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.ts
similarity index 71%
rename from frontend/src/Helpers/Props/sizes.js
rename to frontend/src/Helpers/Props/sizes.ts
index d7f85df5e..ca7a50fbf 100644
--- a/frontend/src/Helpers/Props/sizes.js
+++ b/frontend/src/Helpers/Props/sizes.ts
@@ -4,4 +4,6 @@ export const MEDIUM = 'medium';
export const LARGE = 'large';
export const EXTRA_LARGE = 'extraLarge';
-export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];
+export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE] as const;
+
+export type Size = 'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge';
diff --git a/frontend/src/History/Details/HistoryDetails.js b/frontend/src/History/Details/HistoryDetails.js
index 63543f040..6d5ab260e 100644
--- a/frontend/src/History/Details/HistoryDetails.js
+++ b/frontend/src/History/Details/HistoryDetails.js
@@ -3,6 +3,7 @@ import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Link from 'Components/Link/Link';
+import formatDateTime from 'Utilities/Date/formatDateTime';
import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css';
@@ -10,7 +11,10 @@ function HistoryDetails(props) {
const {
indexer,
eventType,
- data
+ date,
+ data,
+ shortDateFormat,
+ timeFormat
} = props;
if (eventType === 'indexerQuery' || eventType === 'indexerRss') {
@@ -18,8 +22,13 @@ function HistoryDetails(props) {
query,
queryResults,
categories,
+ limit,
+ offset,
source,
- url
+ host,
+ url,
+ elapsedTime,
+ cached
} = data;
return (
@@ -31,43 +40,93 @@ function HistoryDetails(props) {
/>
{
- !!indexer &&
+ indexer ?
+ /> :
+ null
}
{
- !!data &&
+ data ?
+ /> :
+ null
}
{
- !!data &&
+ data ?
+ /> :
+ null
}
{
- !!data &&
+ limit ?
+ :
+ null
+ }
+
+ {
+ offset ?
+ :
+ null
+ }
+
+ {
+ data ?
+ /> :
+ null
}
{
- !!data &&
+ data ?
+ :
+ null
+ }
+
+ {
+ data ?
{translate('Link')} : '-'}
- />
+ /> :
+ null
+ }
+
+ {
+ elapsedTime ?
+ :
+ null
+ }
+
+ {
+ date ?
+ :
+ null
}
);
@@ -76,59 +135,156 @@ function HistoryDetails(props) {
if (eventType === 'releaseGrabbed') {
const {
source,
- title,
- url
+ host,
+ grabTitle,
+ url,
+ publishedDate,
+ infoUrl,
+ downloadClient,
+ downloadClientName,
+ elapsedTime,
+ grabMethod
} = data;
+ const downloadClientNameInfo = downloadClientName ?? downloadClient;
+
return (
{
- !!indexer &&
+ indexer ?
+ /> :
+ null
}
{
- !!data &&
+ data ?
+ /> :
+ null
}
{
- !!data &&
+ data ?
+ title={translate('Host')}
+ data={host}
+ /> :
+ null
}
{
- !!data &&
+ data ?
+ :
+ null
+ }
+
+ {
+ infoUrl ?
+ {infoUrl}}
+ /> :
+ null
+ }
+
+ {
+ publishedDate ?
+ :
+ null
+ }
+
+ {
+ downloadClientNameInfo ?
+ :
+ null
+ }
+
+ {
+ data ?
{translate('Link')} : '-'}
- />
+ /> :
+ null
+ }
+
+ {
+ elapsedTime ?
+ :
+ null
+ }
+
+ {
+ grabMethod ?
+ :
+ null
+ }
+
+ {
+ date ?
+ :
+ null
}
);
}
if (eventType === 'indexerAuth') {
+ const { elapsedTime } = data;
+
return (
{
- !!indexer &&
+ indexer ?
+ /> :
+ null
+ }
+
+ {
+ elapsedTime ?
+ :
+ null
+ }
+
+ {
+ date ?
+ :
+ null
}
);
@@ -141,6 +297,15 @@ function HistoryDetails(props) {
title={translate('Name')}
data={data.query}
/>
+
+ {
+ date ?
+ :
+ null
+ }
);
}
@@ -148,6 +313,7 @@ function HistoryDetails(props) {
HistoryDetails.propTypes = {
indexer: PropTypes.object.isRequired,
eventType: PropTypes.string.isRequired,
+ date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
diff --git a/frontend/src/History/Details/HistoryDetailsModal.css b/frontend/src/History/Details/HistoryDetailsModal.css
deleted file mode 100644
index 271d422ff..000000000
--- a/frontend/src/History/Details/HistoryDetailsModal.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.markAsFailedButton {
- composes: button from '~Components/Link/Button.css';
-
- margin-right: auto;
-}
diff --git a/frontend/src/History/Details/HistoryDetailsModal.js b/frontend/src/History/Details/HistoryDetailsModal.js
index e6f960c48..560955de3 100644
--- a/frontend/src/History/Details/HistoryDetailsModal.js
+++ b/frontend/src/History/Details/HistoryDetailsModal.js
@@ -1,16 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
-import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
-import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import HistoryDetails from './HistoryDetails';
-import styles from './HistoryDetailsModal.css';
function getHeaderTitle(eventType) {
switch (eventType) {
@@ -32,11 +29,10 @@ function HistoryDetailsModal(props) {
isOpen,
eventType,
indexer,
+ date,
data,
- isMarkingAsFailed,
shortDateFormat,
timeFormat,
- onMarkAsFailedPress,
onModalClose
} = props;
@@ -54,6 +50,7 @@ function HistoryDetailsModal(props) {
- {
- eventType === 'grabbed' &&
-
- Mark as Failed
-
- }
-
- {translate('{0} indexers selected', selectedCount.toString())}
+ {translate('CountIndexersSelected', { count: selectedCount })}
;
onPress: () => void;
}
@@ -20,7 +20,7 @@ function IndexerIndexSelectModeButton(
const onPressWrapper = useCallback(() => {
if (isSelectMode) {
selectDispatch({
- type: SelectActionType.Reset,
+ type: 'reset',
});
}
diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx
index f7d63950f..8f1c0623f 100644
--- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx
+++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx
@@ -1,6 +1,6 @@
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
import React, { useCallback } from 'react';
-import { SelectActionType, useSelect } from 'App/SelectContext';
+import { useSelect } from 'App/SelectContext';
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
interface IndexerIndexSelectModeMenuItemProps {
@@ -19,7 +19,7 @@ function IndexerIndexSelectModeMenuItem(
const onPressWrapper = useCallback(() => {
if (isSelectMode) {
selectDispatch({
- type: SelectActionType.Reset,
+ type: 'reset',
});
}
diff --git a/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx
index a8bbd09f7..1964d271c 100644
--- a/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx
+++ b/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx
@@ -1,6 +1,7 @@
-import { concat, uniq } from 'lodash';
+import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
+import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -12,8 +13,10 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Indexer from 'Indexer/Indexer';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
@@ -25,29 +28,35 @@ interface TagsModalContentProps {
function TagsModalContent(props: TagsModalContentProps) {
const { indexerIds, onModalClose, onApplyTagsPress } = props;
- const allIndexers = useSelector(createAllIndexersSelector());
- const tagList = useSelector(createTagsSelector());
+ const allIndexers: Indexer[] = useSelector(createAllIndexersSelector());
+ const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState([]);
const [applyTags, setApplyTags] = useState('add');
const indexerTags = useMemo(() => {
- const indexers = indexerIds.map((id) => {
- return allIndexers.find((s) => s.id === id);
- });
+ const tags = indexerIds.reduce((acc: number[], id) => {
+ const s = allIndexers.find((s) => s.id === id);
- return uniq(concat(...indexers.map((s) => s.tags)));
+ if (s) {
+ acc.push(...s.tags);
+ }
+
+ return acc;
+ }, []);
+
+ return uniq(tags);
}, [indexerIds, allIndexers]);
const onTagsChange = useCallback(
- ({ value }) => {
+ ({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
- ({ value }) => {
+ ({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
@@ -58,19 +67,19 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
- { key: 'add', value: 'Add' },
- { key: 'remove', value: 'Remove' },
- { key: 'replace', value: 'Replace' },
+ { key: 'add', value: translate('Add') },
+ { key: 'remove', value: translate('Remove') },
+ { key: 'replace', value: translate('Replace') },
];
return (
- Tags
+ {translate('Tags')}
+
+
+
+ {translate('Delete')}
+
+
+ {translate('Clone')}
+
+
+
+ {translate('Edit')}
+ {translate('Close')}
+
);
diff --git a/frontend/src/Indexer/NoIndexer.css b/frontend/src/Indexer/NoIndexer.css
index 38a01f391..4ad534de3 100644
--- a/frontend/src/Indexer/NoIndexer.css
+++ b/frontend/src/Indexer/NoIndexer.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/Indexer/NoIndexer.js b/frontend/src/Indexer/NoIndexer.tsx
similarity index 54%
rename from frontend/src/Indexer/NoIndexer.js
rename to frontend/src/Indexer/NoIndexer.tsx
index f94df7902..bf5afa1fe 100644
--- a/frontend/src/Indexer/NoIndexer.js
+++ b/frontend/src/Indexer/NoIndexer.tsx
@@ -1,23 +1,23 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './NoIndexer.css';
-function NoIndexer(props) {
- const {
- totalItems,
- onAddIndexerPress
- } = props;
+interface NoIndexerProps {
+ totalItems: number;
+ onAddIndexerPress(): void;
+}
+
+function NoIndexer(props: NoIndexerProps) {
+ const { totalItems, onAddIndexerPress } = props;
if (totalItems > 0) {
return (
-
-
- {translate('AllIndexersHiddenDueToFilter')}
-
-
+
+ {translate('AllIndexersHiddenDueToFilter')}
+
);
}
@@ -28,10 +28,7 @@ function NoIndexer(props) {
-
+
{translate('AddNewIndexer')}
@@ -39,9 +36,4 @@ function NoIndexer(props) {
);
}
-NoIndexer.propTypes = {
- totalItems: PropTypes.number.isRequired,
- onAddIndexerPress: PropTypes.func.isRequired
-};
-
export default NoIndexer;
diff --git a/frontend/src/Indexer/Stats/IndexerStats.css b/frontend/src/Indexer/Stats/IndexerStats.css
new file mode 100644
index 000000000..975f5ddae
--- /dev/null
+++ b/frontend/src/Indexer/Stats/IndexerStats.css
@@ -0,0 +1,52 @@
+.fullWidthChart {
+ display: inline-block;
+ width: 100%;
+}
+
+.halfWidthChart {
+ display: inline-block;
+ width: 50%;
+}
+
+.quarterWidthChart {
+ display: inline-block;
+ width: 25%;
+}
+
+.chartContainer {
+ margin: 5px;
+ padding: 15px 25px;
+ height: 300px;
+ border-radius: 10px;
+ background-color: var(--chartBackgroundColor);
+}
+
+.statContainer {
+ margin: 5px;
+ padding: 15px 25px;
+ height: 150px;
+ border-radius: 10px;
+ background-color: var(--chartBackgroundColor);
+}
+
+.statTitle {
+ font-weight: bold;
+ font-size: 14px;
+}
+
+.stat {
+ font-weight: bold;
+ font-size: 60px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .halfWidthChart {
+ display: inline-block;
+ width: 100%;
+ }
+
+ .quarterWidthChart {
+ display: inline-block;
+ width: 100%;
+ }
+}
diff --git a/frontend/src/Indexer/Stats/IndexerStats.css.d.ts b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts
new file mode 100644
index 000000000..e2aae98c6
--- /dev/null
+++ b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'chartContainer': string;
+ 'fullWidthChart': string;
+ 'halfWidthChart': string;
+ 'quarterWidthChart': string;
+ 'stat': string;
+ 'statContainer': string;
+ 'statTitle': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Indexer/Stats/IndexerStats.tsx b/frontend/src/Indexer/Stats/IndexerStats.tsx
new file mode 100644
index 000000000..bccd49cbe
--- /dev/null
+++ b/frontend/src/Indexer/Stats/IndexerStats.tsx
@@ -0,0 +1,373 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import IndexerStatsAppState from 'App/State/IndexerStatsAppState';
+import Alert from 'Components/Alert';
+import BarChart from 'Components/Chart/BarChart';
+import DoughnutChart from 'Components/Chart/DoughnutChart';
+import StackedBarChart from 'Components/Chart/StackedBarChart';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import { align, icons, kinds } from 'Helpers/Props';
+import {
+ fetchIndexerStats,
+ setIndexerStatsFilter,
+} from 'Store/Actions/indexerStatsActions';
+import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
+import {
+ IndexerStatsHost,
+ IndexerStatsIndexer,
+ IndexerStatsUserAgent,
+} from 'typings/IndexerStats';
+import abbreviateNumber from 'Utilities/Number/abbreviateNumber';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import translate from 'Utilities/String/translate';
+import IndexerStatsFilterModal from './IndexerStatsFilterModal';
+import styles from './IndexerStats.css';
+
+function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) {
+ const statistics = [...indexerStats].sort((a, b) =>
+ a.averageResponseTime === b.averageResponseTime
+ ? b.averageGrabResponseTime - a.averageGrabResponseTime
+ : b.averageResponseTime - a.averageResponseTime
+ );
+
+ return {
+ labels: statistics.map((indexer) => indexer.indexerName),
+ datasets: [
+ {
+ label: translate('AverageQueries'),
+ data: statistics.map((indexer) => indexer.averageResponseTime),
+ },
+ {
+ label: translate('AverageGrabs'),
+ data: statistics.map((indexer) => indexer.averageGrabResponseTime),
+ },
+ ],
+ };
+}
+
+function getFailureRateData(indexerStats: IndexerStatsIndexer[]) {
+ const data = [...indexerStats]
+ .map((indexer) => ({
+ label: indexer.indexerName,
+ value:
+ (indexer.numberOfFailedQueries +
+ indexer.numberOfFailedRssQueries +
+ indexer.numberOfFailedAuthQueries +
+ indexer.numberOfFailedGrabs) /
+ (indexer.numberOfQueries +
+ indexer.numberOfRssQueries +
+ indexer.numberOfAuthQueries +
+ indexer.numberOfGrabs),
+ }))
+ .filter((s) => s.value > 0);
+
+ data.sort((a, b) => b.value - a.value);
+
+ return data;
+}
+
+function getTotalRequestsData(indexerStats: IndexerStatsIndexer[]) {
+ const statistics = [...indexerStats]
+ .filter(
+ (s) =>
+ s.numberOfQueries > 0 ||
+ s.numberOfRssQueries > 0 ||
+ s.numberOfAuthQueries > 0
+ )
+ .sort(
+ (a, b) =>
+ b.numberOfQueries +
+ b.numberOfRssQueries +
+ b.numberOfAuthQueries -
+ (a.numberOfQueries + a.numberOfRssQueries + a.numberOfAuthQueries)
+ );
+
+ return {
+ labels: statistics.map((indexer) => indexer.indexerName),
+ datasets: [
+ {
+ label: translate('SearchQueries'),
+ data: statistics.map((indexer) => indexer.numberOfQueries),
+ },
+ {
+ label: translate('RssQueries'),
+ data: statistics.map((indexer) => indexer.numberOfRssQueries),
+ },
+ {
+ label: translate('AuthQueries'),
+ data: statistics.map((indexer) => indexer.numberOfAuthQueries),
+ },
+ ],
+ };
+}
+
+function getNumberGrabsData(indexerStats: IndexerStatsIndexer[]) {
+ const data = [...indexerStats]
+ .map((indexer) => ({
+ label: indexer.indexerName,
+ value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs,
+ }))
+ .filter((s) => s.value > 0);
+
+ data.sort((a, b) => b.value - a.value);
+
+ return data;
+}
+
+function getUserAgentGrabsData(indexerStats: IndexerStatsUserAgent[]) {
+ const data = indexerStats.map((indexer) => ({
+ label: indexer.userAgent ? indexer.userAgent : 'Other',
+ value: indexer.numberOfGrabs,
+ }));
+
+ data.sort((a, b) => b.value - a.value);
+
+ return data;
+}
+
+function getUserAgentQueryData(indexerStats: IndexerStatsUserAgent[]) {
+ const data = indexerStats.map((indexer) => ({
+ label: indexer.userAgent ? indexer.userAgent : 'Other',
+ value: indexer.numberOfQueries,
+ }));
+
+ data.sort((a, b) => b.value - a.value);
+
+ return data;
+}
+
+function getHostGrabsData(indexerStats: IndexerStatsHost[]) {
+ const data = indexerStats.map((indexer) => ({
+ label: indexer.host ? indexer.host : 'Other',
+ value: indexer.numberOfGrabs,
+ }));
+
+ data.sort((a, b) => b.value - a.value);
+
+ return data;
+}
+
+function getHostQueryData(indexerStats: IndexerStatsHost[]) {
+ const data = indexerStats.map((indexer) => ({
+ label: indexer.host ? indexer.host : 'Other',
+ value: indexer.numberOfQueries,
+ }));
+
+ data.sort((a, b) => b.value - a.value);
+
+ return data;
+}
+
+const indexerStatsSelector = () => {
+ return createSelector(
+ (state: AppState) => state.indexerStats,
+ createCustomFiltersSelector('indexerStats'),
+ (indexerStats: IndexerStatsAppState, customFilters) => {
+ return {
+ ...indexerStats,
+ customFilters,
+ };
+ }
+ );
+};
+
+function IndexerStats() {
+ const {
+ isFetching,
+ isPopulated,
+ item,
+ error,
+ filters,
+ customFilters,
+ selectedFilterKey,
+ } = useSelector(indexerStatsSelector());
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ dispatch(fetchIndexerStats());
+ }, [dispatch]);
+
+ const onRefreshPress = useCallback(() => {
+ dispatch(fetchIndexerStats());
+ }, [dispatch]);
+
+ const onFilterSelect = useCallback(
+ (value: string) => {
+ dispatch(setIndexerStatsFilter({ selectedFilterKey: value }));
+ },
+ [dispatch]
+ );
+
+ const isLoaded = !error && isPopulated;
+ const indexerCount = item.indexers?.length ?? 0;
+ const userAgentCount = item.userAgents?.length ?? 0;
+ const queryCount =
+ item.indexers?.reduce((total, indexer) => {
+ return (
+ total +
+ indexer.numberOfQueries +
+ indexer.numberOfRssQueries +
+ indexer.numberOfAuthQueries
+ );
+ }, 0) ?? 0;
+ const grabCount =
+ item.indexers?.reduce((total, indexer) => {
+ return total + indexer.numberOfGrabs;
+ }, 0) ?? 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isFetching && !isPopulated && }
+
+ {!isFetching && !!error && (
+
+ {getErrorMessage(error, 'Failed to load indexer stats from API')}
+
+ )}
+
+ {isLoaded && (
+
+
+
+
+ {translate('ActiveIndexers')}
+
+
{indexerCount}
+
+
+
+
+
+ {translate('TotalQueries')}
+
+
+ {abbreviateNumber(queryCount)}
+
+
+
+
+
+
+ {translate('TotalGrabs')}
+
+
{abbreviateNumber(grabCount)}
+
+
+
+
+
+ {translate('ActiveApps')}
+
+
{userAgentCount}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default IndexerStats;
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/Stats/Stats.css b/frontend/src/Indexer/Stats/Stats.css
deleted file mode 100644
index 249dcc448..000000000
--- a/frontend/src/Indexer/Stats/Stats.css
+++ /dev/null
@@ -1,22 +0,0 @@
-.fullWidthChart {
- display: inline-block;
- padding: 15px 25px;
- width: 100%;
- height: 300px;
-}
-
-.halfWidthChart {
- display: inline-block;
- padding: 15px 25px;
- width: 50%;
- height: 300px;
-}
-
-@media only screen and (max-width: $breakpointSmall) {
- .halfWidthChart {
- display: inline-block;
- padding: 15px 25px;
- width: 100%;
- height: 300px;
- }
-}
diff --git a/frontend/src/Indexer/Stats/Stats.js b/frontend/src/Indexer/Stats/Stats.js
deleted file mode 100644
index b063d638c..000000000
--- a/frontend/src/Indexer/Stats/Stats.js
+++ /dev/null
@@ -1,260 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import BarChart from 'Components/Chart/BarChart';
-import DoughnutChart from 'Components/Chart/DoughnutChart';
-import StackedBarChart from 'Components/Chart/StackedBarChart';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import PageContent from 'Components/Page/PageContent';
-import PageContentBody from 'Components/Page/PageContentBody';
-import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
-import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
-import { align, kinds } from 'Helpers/Props';
-import getErrorMessage from 'Utilities/Object/getErrorMessage';
-import StatsFilterMenu from './StatsFilterMenu';
-import styles from './Stats.css';
-
-function getAverageResponseTimeData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.indexerName,
- value: indexer.averageResponseTime
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function getFailureRateData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.indexerName,
- value: (indexer.numberOfFailedQueries + indexer.numberOfFailedRssQueries + indexer.numberOfFailedAuthQueries + indexer.numberOfFailedGrabs) /
- (indexer.numberOfQueries + indexer.numberOfRssQueries + indexer.numberOfAuthQueries + indexer.numberOfGrabs)
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function getTotalRequestsData(indexerStats) {
- const data = {
- labels: indexerStats.map((indexer) => indexer.indexerName),
- datasets: [
- {
- label: 'Search Queries',
- data: indexerStats.map((indexer) => indexer.numberOfQueries)
- },
- {
- label: 'Rss Queries',
- data: indexerStats.map((indexer) => indexer.numberOfRssQueries)
- },
- {
- label: 'Auth Queries',
- data: indexerStats.map((indexer) => indexer.numberOfAuthQueries)
- }
- ]
- };
-
- return data;
-}
-
-function getNumberGrabsData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.indexerName,
- value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function getUserAgentGrabsData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.userAgent ? indexer.userAgent : 'Other',
- value: indexer.numberOfGrabs
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function getUserAgentQueryData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.userAgent ? indexer.userAgent : 'Other',
- value: indexer.numberOfQueries
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function getHostGrabsData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.host ? indexer.host : 'Other',
- value: indexer.numberOfGrabs
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function getHostQueryData(indexerStats) {
- const data = indexerStats.map((indexer) => {
- return {
- label: indexer.host ? indexer.host : 'Other',
- value: indexer.numberOfQueries
- };
- });
-
- data.sort((a, b) => {
- return b.value - a.value;
- });
-
- return data;
-}
-
-function Stats(props) {
- const {
- item,
- isFetching,
- isPopulated,
- error,
- filters,
- selectedFilterKey,
- onFilterSelect
- } = props;
-
- const isLoaded = !!(!error && isPopulated);
-
- return (
-
-
-
-
-
-
-
- {
- isFetching && !isPopulated &&
-
- }
-
- {
- !isFetching && !!error &&
-
- {getErrorMessage(error, 'Failed to load indexer stats from API')}
-
- }
-
- {
- isLoaded &&
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
-
-
- );
-}
-
-Stats.propTypes = {
- item: PropTypes.object.isRequired,
- isFetching: PropTypes.bool.isRequired,
- isPopulated: PropTypes.bool.isRequired,
- filters: PropTypes.arrayOf(PropTypes.object).isRequired,
- selectedFilterKey: PropTypes.string.isRequired,
- customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
- onFilterSelect: PropTypes.func.isRequired,
- error: PropTypes.object,
- data: PropTypes.object
-};
-
-export default Stats;
diff --git a/frontend/src/Indexer/Stats/StatsConnector.js b/frontend/src/Indexer/Stats/StatsConnector.js
deleted file mode 100644
index 006716953..000000000
--- a/frontend/src/Indexer/Stats/StatsConnector.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchIndexerStats, setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions';
-import Stats from './Stats';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.indexerStats,
- (indexerStats) => indexerStats
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onFilterSelect(selectedFilterKey) {
- dispatch(setIndexerStatsFilter({ selectedFilterKey }));
- },
- dispatchFetchIndexerStats() {
- dispatch(fetchIndexerStats());
- }
- };
-}
-
-class StatsConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- this.props.dispatchFetchIndexerStats();
- }
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-StatsConnector.propTypes = {
- dispatchFetchIndexerStats: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, createMapDispatchToProps)(StatsConnector);
diff --git a/frontend/src/Indexer/Stats/StatsFilterMenu.js b/frontend/src/Indexer/Stats/StatsFilterMenu.js
deleted file mode 100644
index 283159b7e..000000000
--- a/frontend/src/Indexer/Stats/StatsFilterMenu.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import FilterMenu from 'Components/Menu/FilterMenu';
-import { align } from 'Helpers/Props';
-
-function StatsFilterMenu(props) {
- const {
- selectedFilterKey,
- filters,
- isDisabled,
- onFilterSelect
- } = props;
-
- return (
-
- );
-}
-
-StatsFilterMenu.propTypes = {
- selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- filters: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDisabled: PropTypes.bool.isRequired,
- onFilterSelect: PropTypes.func.isRequired
-};
-
-StatsFilterMenu.defaultProps = {
- showCustomFilters: false
-};
-
-export default StatsFilterMenu;
diff --git a/frontend/src/Indexer/Stats/StatsFilterModalConnector.js b/frontend/src/Indexer/Stats/StatsFilterModalConnector.js
deleted file mode 100644
index 53bf2ed3c..000000000
--- a/frontend/src/Indexer/Stats/StatsFilterModalConnector.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import FilterModal from 'Components/Filter/FilterModal';
-import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.indexerStats.items,
- (state) => state.indexerStats.filterBuilderProps,
- (sectionItems, filterBuilderProps) => {
- return {
- sectionItems,
- filterBuilderProps,
- customFilterType: 'indexerStats'
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchSetFilter: setIndexerStatsFilter
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
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/Menus/SearchIndexFilterMenu.tsx b/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx
index 52806ff83..9a8c243b4 100644
--- a/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx
+++ b/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx
@@ -1,10 +1,18 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import { CustomFilter } from 'App/State/AppState';
import FilterMenu from 'Components/Menu/FilterMenu';
import { align } from 'Helpers/Props';
import SearchIndexFilterModalConnector from 'Search/SearchIndexFilterModalConnector';
-function SearchIndexFilterMenu(props) {
+interface SearchIndexFilterMenuProps {
+ selectedFilterKey: string | number;
+ filters: object[];
+ customFilters: CustomFilter[];
+ isDisabled: boolean;
+ onFilterSelect(filterName: string): unknown;
+}
+
+function SearchIndexFilterMenu(props: SearchIndexFilterMenuProps) {
const {
selectedFilterKey,
filters,
@@ -26,15 +34,6 @@ function SearchIndexFilterMenu(props) {
);
}
-SearchIndexFilterMenu.propTypes = {
- selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
- .isRequired,
- filters: PropTypes.arrayOf(PropTypes.object).isRequired,
- customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDisabled: PropTypes.bool.isRequired,
- onFilterSelect: PropTypes.func.isRequired,
-};
-
SearchIndexFilterMenu.defaultProps = {
showCustomFilters: false,
};
diff --git a/frontend/src/Search/Menus/SearchIndexSortMenu.tsx b/frontend/src/Search/Menus/SearchIndexSortMenu.tsx
index 302ef6a10..af4042283 100644
--- a/frontend/src/Search/Menus/SearchIndexSortMenu.tsx
+++ b/frontend/src/Search/Menus/SearchIndexSortMenu.tsx
@@ -1,12 +1,19 @@
-import PropTypes from 'prop-types';
import React from 'react';
import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
-import { align, sortDirections } from 'Helpers/Props';
+import { align } from 'Helpers/Props';
+import SortDirection from 'Helpers/Props/SortDirection';
import translate from 'Utilities/String/translate';
-function SearchIndexSortMenu(props) {
+interface SearchIndexSortMenuProps {
+ sortKey?: string;
+ sortDirection?: SortDirection;
+ isDisabled: boolean;
+ onSortSelect(sortKey: string): unknown;
+}
+
+function SearchIndexSortMenu(props: SearchIndexSortMenuProps) {
const { sortKey, sortDirection, isDisabled, onSortSelect } = props;
return (
@@ -97,11 +104,4 @@ function SearchIndexSortMenu(props) {
);
}
-SearchIndexSortMenu.propTypes = {
- sortKey: PropTypes.string,
- sortDirection: PropTypes.oneOf(sortDirections.all),
- isDisabled: PropTypes.bool.isRequired,
- onSortSelect: PropTypes.func.isRequired,
-};
-
export default SearchIndexSortMenu;
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 7294d1f6c..000000000
--- a/frontend/src/Search/Mobile/SearchIndexOverview.js
+++ /dev/null
@@ -1,202 +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 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 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,
- categories,
- seeders,
- leechers,
- size,
- age,
- ageHours,
- ageMinutes,
- indexer,
- rowHeight,
- isSmallScreen,
- isGrabbed,
- isGrabbing,
- grabError
- } = this.props;
-
- const contentHeight = getContentHeight(rowHeight, isSmallScreen);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {indexer}
-
-
-
-
- {
- protocol === 'torrent' &&
-
- }
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-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.isRequired,
- 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/Mobile/SearchIndexOverviews.js b/frontend/src/Search/Mobile/SearchIndexOverviews.js
index 7fadb51e0..671c8e9fd 100644
--- a/frontend/src/Search/Mobile/SearchIndexOverviews.js
+++ b/frontend/src/Search/Mobile/SearchIndexOverviews.js
@@ -195,7 +195,7 @@ class SearchIndexOverviews extends Component {
SearchIndexOverviews.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
- scrollTop: PropTypes.number.isRequired,
+ scrollTop: PropTypes.number,
jumpToCharacter: PropTypes.string,
scroller: PropTypes.instanceOf(Element).isRequired,
showRelativeDates: PropTypes.bool.isRequired,
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.js b/frontend/src/Search/NoSearchResults.js
deleted file mode 100644
index 03fce4be9..000000000
--- a/frontend/src/Search/NoSearchResults.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import translate from 'Utilities/String/translate';
-import styles from './NoSearchResults.css';
-
-function NoSearchResults(props) {
- const { totalItems } = props;
-
- if (totalItems > 0) {
- return (
-
-
- {translate('AllIndexersHiddenDueToFilter')}
-
-
- );
- }
-
- return (
-
-
- {translate('NoSearchResultsFound')}
-
-
- );
-}
-
-NoSearchResults.propTypes = {
- totalItems: PropTypes.number.isRequired
-};
-
-export default NoSearchResults;
diff --git a/frontend/src/Search/NoSearchResults.tsx b/frontend/src/Search/NoSearchResults.tsx
new file mode 100644
index 000000000..46fbc85e0
--- /dev/null
+++ b/frontend/src/Search/NoSearchResults.tsx
@@ -0,0 +1,29 @@
+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';
+
+interface NoSearchResultsProps {
+ totalItems: number;
+}
+
+function NoSearchResults(props: NoSearchResultsProps) {
+ const { totalItems } = props;
+
+ if (totalItems > 0) {
+ return (
+
+ {translate('AllSearchResultsHiddenByFilter')}
+
+ );
+ }
+
+ return (
+
+ {translate('NoSearchResultsFound')}
+
+ );
+}
+
+export default NoSearchResults;
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}
+
+
+
+ {translate('Cancel')}
+
+
+ );
+}
+
+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/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
new file mode 100644
index 000000000..10c2d3948
--- /dev/null
+++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ '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('Cancel')}
+
+
+ {translate('GrabRelease')}
+
+
+
+
+
+
+ );
+}
+
+export default OverrideMatchModalContent;
diff --git a/frontend/src/Search/QueryParameterModal.js b/frontend/src/Search/QueryParameterModal.js
index df06648a2..cd7b8e191 100644
--- a/frontend/src/Search/QueryParameterModal.js
+++ b/frontend/src/Search/QueryParameterModal.js
@@ -14,11 +14,11 @@ import QueryParameterOption from './QueryParameterOption';
import styles from './QueryParameterModal.css';
const searchOptions = [
- { key: 'search', value: 'Basic Search' },
- { key: 'tvsearch', value: 'TV Search' },
- { key: 'movie', value: 'Movie Search' },
- { key: 'music', value: 'Audio Search' },
- { key: 'book', value: 'Book Search' }
+ { key: 'search', value: () => translate('BasicSearch') },
+ { key: 'tvsearch', value: () => translate('TvSearch') },
+ { key: 'movie', value: () => translate('MovieSearch') },
+ { key: 'music', value: () => translate( 'AudioSearch') },
+ { key: 'book', value: () => translate('BookSearch') }
];
const seriesTokens = [
@@ -94,8 +94,8 @@ class QueryParameterModal extends Component {
const newValue = `${start}${tokenValue}${end}`;
onSearchInputChange({ name, value: newValue });
- this._selectionStart = newValue.length - 1;
- this._selectionEnd = newValue.length - 1;
+ this._selectionStart = newValue.length;
+ this._selectionEnd = newValue.length;
}
};
diff --git a/frontend/src/Search/SearchFooter.css b/frontend/src/Search/SearchFooter.css
index 54e68660b..65121e5e3 100644
--- a/frontend/src/Search/SearchFooter.css
+++ b/frontend/src/Search/SearchFooter.css
@@ -24,7 +24,8 @@
flex-grow: 1;
}
-.searchButton {
+.searchButton,
+.grabReleasesButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-left: 25px;
@@ -32,18 +33,20 @@
}
.selectedReleasesLabel {
- margin-bottom: 3px;
+ margin-bottom: 5px;
text-align: right;
font-weight: bold;
}
@media only screen and (max-width: $breakpointSmall) {
- .inputContainer {
+ .inputContainer,
+ .indexerContainer {
margin-right: 0;
}
.buttonContainer {
justify-content: flex-start;
+ margin-top: 10px;
}
.buttonContainerContent {
@@ -52,5 +55,20 @@
.buttons {
justify-content: space-between;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .grabReleasesButton,
+ .searchButton {
+ margin-left: 0;
+ }
+
+ .grabReleasesButton {
+ display: none;
+ }
+
+ .selectedReleasesLabel {
+ text-align: center;
}
}
diff --git a/frontend/src/Search/SearchFooter.css.d.ts b/frontend/src/Search/SearchFooter.css.d.ts
index 8bf441cf4..e72f81320 100644
--- a/frontend/src/Search/SearchFooter.css.d.ts
+++ b/frontend/src/Search/SearchFooter.css.d.ts
@@ -4,6 +4,7 @@ interface CssExports {
'buttonContainer': string;
'buttonContainerContent': string;
'buttons': string;
+ 'grabReleasesButton': string;
'indexerContainer': string;
'inputContainer': string;
'searchButton': string;
diff --git a/frontend/src/Search/SearchFooter.js b/frontend/src/Search/SearchFooter.js
index 86f26fc51..872328446 100644
--- a/frontend/src/Search/SearchFooter.js
+++ b/frontend/src/Search/SearchFooter.js
@@ -24,23 +24,26 @@ class SearchFooter extends Component {
super(props, context);
const {
+ defaultSearchQueryParams,
defaultIndexerIds,
defaultCategories,
defaultSearchQuery,
- defaultSearchType
+ defaultSearchType,
+ defaultSearchLimit,
+ defaultSearchOffset
} = props;
this.state = {
- isQueryParameterModalOpen: false,
- queryModalOptions: null,
- searchType: defaultSearchType,
+ searchIndexerIds: defaultSearchQueryParams.searchIndexerIds ?? defaultIndexerIds,
+ searchCategories: defaultSearchQueryParams.searchCategories ?? defaultCategories,
+ searchQuery: (defaultSearchQueryParams.searchQuery ?? defaultSearchQuery) || '',
+ searchType: defaultSearchQueryParams.searchType ?? defaultSearchType,
+ searchLimit: defaultSearchQueryParams.searchLimit ?? defaultSearchLimit,
+ searchOffset: defaultSearchQueryParams.searchOffset ?? defaultSearchOffset,
+ newSearch: true,
searchingReleases: false,
- searchQuery: defaultSearchQuery || '',
- searchIndexerIds: defaultIndexerIds,
- searchCategories: defaultCategories,
- searchLimit: 100,
- searchOffset: 0,
- newSearch: true
+ isQueryParameterModalOpen: false,
+ queryModalOptions: null
};
}
@@ -55,7 +58,9 @@ class SearchFooter extends Component {
this.onSearchPress();
}
- this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true });
+ setTimeout(() => {
+ this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true });
+ });
}
componentDidUpdate(prevProps) {
@@ -120,7 +125,6 @@ class SearchFooter extends Component {
};
onSearchPress = () => {
-
const {
searchLimit,
searchOffset,
@@ -186,12 +190,13 @@ class SearchFooter extends Component {
break;
default:
icon = icons.SEARCH;
+ break;
}
- let footerLabel = `Search ${searchIndexerIds.length === 0 ? 'all' : searchIndexerIds.length} Indexers`;
+ let footerLabel = searchIndexerIds.length === 0 ? translate('SearchAllIndexers') : translate('SearchCountIndexers', { count: searchIndexerIds.length });
if (isPopulated) {
- footerLabel = selectedCount === 0 ? `Found ${itemCount} releases` : `Selected ${selectedCount} of ${itemCount} releases`;
+ footerLabel = selectedCount === 0 ? translate('FoundCountReleases', { itemCount }) : translate('SelectedCountOfCountReleases', { selectedCount, itemCount });
}
return (
@@ -207,7 +212,11 @@ class SearchFooter extends Component {
name="searchQuery"
value={searchQuery}
buttons={
-
+
@@ -256,11 +265,10 @@ class SearchFooter extends Component {
/>
-
{
isPopulated &&
state.releases,
- (releases) => {
+ (state) => state.router.location,
+ (releases, location) => {
const {
searchQuery: defaultSearchQuery,
searchIndexerIds: defaultIndexerIds,
searchCategories: defaultCategories,
- searchType: defaultSearchType
+ searchType: defaultSearchType,
+ searchLimit: defaultSearchLimit,
+ searchOffset: defaultSearchOffset
} = releases.defaults;
+ const { params } = parseUrl(location.search);
+ const defaultSearchQueryParams = {};
+
+ if (params.query && !defaultSearchQuery) {
+ defaultSearchQueryParams.searchQuery = params.query;
+ }
+
+ if (params.indexerIds && !defaultIndexerIds.length) {
+ defaultSearchQueryParams.searchIndexerIds = params.indexerIds.split(',').map((id) => Number(id)).filter(Boolean);
+ }
+
+ if (params.categories && !defaultCategories.length) {
+ defaultSearchQueryParams.searchCategories = params.categories.split(',').map((id) => Number(id)).filter(Boolean);
+ }
+
+ if (params.type && defaultSearchType === 'search') {
+ defaultSearchQueryParams.searchType = params.type;
+ }
+
+ if (params.limit && defaultSearchLimit === 100 && !isNaN(params.limit)) {
+ defaultSearchQueryParams.searchLimit = Number(params.limit);
+ }
+
+ if (params.offset && !defaultSearchOffset && !isNaN(params.offset)) {
+ defaultSearchQueryParams.searchOffset = Number(params.offset);
+ }
+
return {
+ defaultSearchQueryParams,
defaultSearchQuery,
defaultIndexerIds,
defaultCategories,
- defaultSearchType
+ defaultSearchType,
+ defaultSearchLimit,
+ defaultSearchOffset
};
}
);
@@ -32,6 +66,16 @@ const mapDispatchToProps = {
class SearchFooterConnector extends Component {
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ // Set defaults from query parameters
+ Object.entries(this.props.defaultSearchQueryParams).forEach(([name, value]) => {
+ this.onInputChange({ name, value });
+ });
+ }
+
//
// Listeners
@@ -53,6 +97,7 @@ class SearchFooterConnector extends Component {
}
SearchFooterConnector.propTypes = {
+ defaultSearchQueryParams: PropTypes.object.isRequired,
setSearchDefault: PropTypes.func.isRequired
};
diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js
index 012dc48da..d12635070 100644
--- a/frontend/src/Search/SearchIndex.js
+++ b/frontend/src/Search/SearchIndex.js
@@ -1,6 +1,7 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
@@ -10,7 +11,9 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
-import { align, icons, sortDirections } from 'Helpers/Props';
+import { align, icons, kinds, sortDirections } from 'Helpers/Props';
+import AddIndexerModal from 'Indexer/Add/AddIndexerModal';
+import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import NoIndexer from 'Indexer/NoIndexer';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
@@ -27,13 +30,7 @@ import SearchFooterConnector from './SearchFooterConnector';
import SearchIndexTableConnector from './Table/SearchIndexTableConnector';
import styles from './SearchIndex.css';
-function getViewComponent(isSmallScreen) {
- if (isSmallScreen) {
- return SearchIndexOverviewsConnector;
- }
-
- return SearchIndexTableConnector;
-}
+const getViewComponent = (isSmallScreen) => (isSmallScreen ? SearchIndexOverviewsConnector : SearchIndexTableConnector);
class SearchIndex extends Component {
@@ -53,7 +50,9 @@ class SearchIndex extends Component {
lastToggled: null,
allSelected: false,
allUnselected: false,
- selectedState: {}
+ selectedState: {},
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: false
};
}
@@ -73,7 +72,7 @@ class SearchIndex extends Component {
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
- hasDifferentItemsOrOrder(prevProps.items, items)
+ hasDifferentItemsOrOrder(prevProps.items, items, 'guid')
) {
this.setJumpBarItems();
this.setSelectedState();
@@ -95,7 +94,14 @@ class SearchIndex extends Component {
if (this.state.allUnselected) {
return [];
}
- return getSelectedIds(this.state.selectedState, { parseIds: false });
+
+ return _.reduce(this.state.selectedState, (result, value, id) => {
+ if (value) {
+ result.push(id);
+ }
+
+ return result;
+ }, []);
};
setSelectedState() {
@@ -141,7 +147,7 @@ class SearchIndex extends Component {
} = this.props;
// Reset if not sorting by sortTitle
- if (sortKey !== 'title') {
+ if (sortKey !== 'sortTitle') {
this.setState({ jumpBarItems: { order: [] } });
return;
}
@@ -149,7 +155,7 @@ class SearchIndex extends Component {
const characters = _.reduce(items, (acc, item) => {
let char = item.sortTitle.charAt(0);
- if (!isNaN(char)) {
+ if (!isNaN(Number(char))) {
char = '#';
}
@@ -180,6 +186,22 @@ class SearchIndex extends Component {
//
// Listeners
+ onAddIndexerPress = () => {
+ this.setState({ isAddIndexerModalOpen: true });
+ };
+
+ onAddIndexerModalClose = () => {
+ this.setState({ isAddIndexerModalOpen: false });
+ };
+
+ onAddIndexerSelectIndexer = () => {
+ this.setState({ isEditIndexerModalOpen: true });
+ };
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ };
+
onJumpBarItemPress = (jumpToCharacter) => {
this.setState({ jumpToCharacter });
};
@@ -251,17 +273,19 @@ class SearchIndex extends Component {
jumpToCharacter,
selectedState,
allSelected,
- allUnselected
+ allUnselected,
+ isAddIndexerModalOpen,
+ isEditIndexerModalOpen
} = this.state;
const selectedIndexerIds = this.getSelectedIds();
const ViewComponent = getViewComponent(isSmallScreen);
const isLoaded = !!(!error && isPopulated && items.length && this.scrollerRef.current);
- const hasNoIndexer = !totalItems;
+ const hasNoSearchResults = !totalItems;
return (
-
+
@@ -290,7 +314,7 @@ class SearchIndex extends Component {
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
- isDisabled={hasNoIndexer}
+ isDisabled={hasNoSearchResults}
onFilterSelect={onFilterSelect}
/>
@@ -303,15 +327,17 @@ class SearchIndex extends Component {
innerClassName={styles.tableInnerContentBody}
>
{
- isFetching && !isPopulated &&
-
+ isFetching && !isPopulated ?
+ :
+ null
}
{
- !isFetching && !!error &&
-
+ !isFetching && !!error ?
+
{getErrorMessage(error, 'Failed to load search results from API')}
-
+ :
+ null
}
{
@@ -336,25 +362,39 @@ class SearchIndex extends Component {
}
{
- !error && !isFetching && !hasIndexers &&
+ !error && !isFetching && !hasIndexers ?
+ /> :
+ null
}
{
- !error && !isFetching && hasIndexers && !items.length &&
-
+ !error && !isFetching && isPopulated && hasIndexers && !items.length ?
+ :
+ null
}
+
+
+
+
{
- isLoaded && !!jumpBarItems.order.length &&
+ isLoaded && !!jumpBarItems.order.length ?
+ /> :
+ null
}
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 80ca3a61d..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 { kinds, tooltipPositions } from 'Helpers/Props';
-import Tooltip from '../../Components/Tooltip/Tooltip';
-
-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/SearchIndexHeader.js b/frontend/src/Search/Table/SearchIndexHeader.js
index 6b91adb45..17b79e2f7 100644
--- a/frontend/src/Search/Table/SearchIndexHeader.js
+++ b/frontend/src/Search/Table/SearchIndexHeader.js
@@ -96,7 +96,7 @@ class SearchIndexHeader extends Component {
isSortable={isSortable}
{...otherProps}
>
- {label}
+ {typeof label === 'function' ? label() : label}
);
})
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 2e8282268..b36ec4071 100644
--- a/frontend/src/Search/Table/SearchIndexRow.css
+++ b/frontend/src/Search/Table/SearchIndexRow.css
@@ -59,10 +59,41 @@
margin: 0 2px;
width: 22px;
color: var(--textColor);
+ text-align: center;
}
.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 1b740b5da..000000000
--- a/frontend/src/Search/Table/SearchIndexRow.js
+++ /dev/null
@@ -1,366 +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 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,
- 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 (
-
-
-
-
-
- );
- }
-
- 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.isRequired,
- 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/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js
index b441ce28a..24383bb1e 100644
--- a/frontend/src/Settings/AdvancedSettingsButton.js
+++ b/frontend/src/Settings/AdvancedSettingsButton.js
@@ -17,7 +17,7 @@ function AdvancedSettingsButton(props) {
return (
-
-
-
-
-
-
-
- }
- />
-
-
-
-
-
-
- );
- }
-}
-
-ApplicationSettings.propTypes = {
- isTestingAll: PropTypes.bool.isRequired,
- isSyncingIndexers: PropTypes.bool.isRequired,
- onTestAllPress: PropTypes.func.isRequired,
- onAppIndexerSyncPress: PropTypes.func.isRequired
-};
-
-export default ApplicationSettings;
diff --git a/frontend/src/Settings/Applications/ApplicationSettings.tsx b/frontend/src/Settings/Applications/ApplicationSettings.tsx
new file mode 100644
index 000000000..7fc4b1d7b
--- /dev/null
+++ b/frontend/src/Settings/Applications/ApplicationSettings.tsx
@@ -0,0 +1,102 @@
+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';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import { icons } from 'Helpers/Props';
+import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { testAllApplications } from 'Store/Actions/Settings/applications';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import translate from 'Utilities/String/translate';
+import ApplicationsConnector from './Applications/ApplicationsConnector';
+import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal';
+
+function ApplicationSettings() {
+ const isSyncingIndexers = useSelector(
+ createCommandExecutingSelector(APP_INDEXER_SYNC)
+ );
+ const isTestingAll = useSelector(
+ (state: AppState) => state.settings.applications.isTestingAll
+ );
+ const dispatch = useDispatch();
+
+ const [isManageApplicationsOpen, setIsManageApplicationsOpen] =
+ useState(false);
+
+ const onManageApplicationsPress = useCallback(() => {
+ setIsManageApplicationsOpen(true);
+ }, [setIsManageApplicationsOpen]);
+
+ const onManageApplicationsModalClose = useCallback(() => {
+ setIsManageApplicationsOpen(false);
+ }, [setIsManageApplicationsOpen]);
+
+ const onAppIndexerSyncPress = useCallback(() => {
+ dispatch(
+ executeCommand({
+ name: APP_INDEXER_SYNC,
+ forceSync: true,
+ })
+ );
+ }, [dispatch]);
+
+ const onTestAllPress = useCallback(() => {
+ dispatch(testAllApplications());
+ }, [dispatch]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+ {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
+ {/* @ts-ignore */}
+
+ {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
+ {/* @ts-ignore */}
+
+
+
+
+
+ );
+}
+
+export default ApplicationSettings;
diff --git a/frontend/src/Settings/Applications/ApplicationSettingsConnector.js b/frontend/src/Settings/Applications/ApplicationSettingsConnector.js
deleted file mode 100644
index aece6e91f..000000000
--- a/frontend/src/Settings/Applications/ApplicationSettingsConnector.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import * as commandNames from 'Commands/commandNames';
-import { executeCommand } from 'Store/Actions/commandActions';
-import { testAllApplications } from 'Store/Actions/settingsActions';
-import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
-import ApplicationSettings from './ApplicationSettings';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.settings.applications.isTestingAll,
- createCommandExecutingSelector(commandNames.APP_INDEXER_SYNC),
- (isTestingAll, isSyncingIndexers) => {
- return {
- isTestingAll,
- isSyncingIndexers
- };
- }
- );
-}
-
-function mapDispatchToProps(dispatch, props) {
- return {
- onTestAllPress() {
- dispatch(testAllApplications());
- },
- onAppIndexerSyncPress() {
- dispatch(executeCommand({
- name: commandNames.APP_INDEXER_SYNC
- }));
- }
- };
-}
-
-export default connect(createMapStateToProps, mapDispatchToProps)(ApplicationSettings);
diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationItem.js b/frontend/src/Settings/Applications/Applications/AddApplicationItem.js
index bb0053824..bae97990b 100644
--- a/frontend/src/Settings/Applications/Applications/AddApplicationItem.js
+++ b/frontend/src/Settings/Applications/Applications/AddApplicationItem.js
@@ -16,10 +16,11 @@ class AddApplicationItem extends Component {
onApplicationSelect = () => {
const {
- implementation
+ implementation,
+ implementationName
} = this.props;
- this.props.onApplicationSelect({ implementation });
+ this.props.onApplicationSelect({ implementation, implementationName });
};
//
@@ -77,6 +78,7 @@ class AddApplicationItem extends Component {
key={preset.name}
name={preset.name}
implementation={implementation}
+ implementationName={implementationName}
onPress={onApplicationSelect}
/>
);
diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js b/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js
index 9974f7132..d04aef4f0 100644
--- a/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js
+++ b/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js
@@ -10,12 +10,14 @@ class AddApplicationPresetMenuItem extends Component {
onPress = () => {
const {
name,
- implementation
+ implementation,
+ implementationName
} = this.props;
this.props.onPress({
name,
- implementation
+ implementation,
+ implementationName
});
};
@@ -26,6 +28,7 @@ class AddApplicationPresetMenuItem extends Component {
const {
name,
implementation,
+ implementationName,
...otherProps
} = this.props;
@@ -43,6 +46,7 @@ class AddApplicationPresetMenuItem extends Component {
AddApplicationPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
diff --git a/frontend/src/Settings/Applications/Applications/Application.css b/frontend/src/Settings/Applications/Applications/Application.css
index 93912850e..2fde249c7 100644
--- a/frontend/src/Settings/Applications/Applications/Application.css
+++ b/frontend/src/Settings/Applications/Applications/Application.css
@@ -4,6 +4,11 @@
width: 290px;
}
+.nameContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
.name {
@add-mixin truncate;
@@ -12,6 +17,12 @@
font-size: 24px;
}
+.externalLink {
+ composes: button from '~Components/Link/IconButton.css';
+
+ height: 36px;
+}
+
.enabled {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/Settings/Applications/Applications/Application.css.d.ts b/frontend/src/Settings/Applications/Applications/Application.css.d.ts
index 58a29f414..085b1a3c5 100644
--- a/frontend/src/Settings/Applications/Applications/Application.css.d.ts
+++ b/frontend/src/Settings/Applications/Applications/Application.css.d.ts
@@ -3,7 +3,9 @@
interface CssExports {
'application': string;
'enabled': string;
+ 'externalLink': string;
'name': string;
+ 'nameContainer': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Settings/Applications/Applications/Application.js b/frontend/src/Settings/Applications/Applications/Application.js
index 728747ecf..086d39ee1 100644
--- a/frontend/src/Settings/Applications/Applications/Application.js
+++ b/frontend/src/Settings/Applications/Applications/Application.js
@@ -2,8 +2,10 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
-import { kinds } from 'Helpers/Props';
+import TagList from 'Components/TagList';
+import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditApplicationModalConnector from './EditApplicationModalConnector';
import styles from './Application.css';
@@ -55,17 +57,35 @@ class Application extends Component {
const {
id,
name,
- syncLevel
+ enable,
+ syncLevel,
+ fields,
+ tags,
+ tagList
} = this.props;
+ const applicationUrl = fields.find((field) => field.name === 'baseUrl')?.value;
+
return (
-
- {name}
+
+
+ {name}
+
+
+ {
+ enable && applicationUrl ?
+
: null
+ }
{
@@ -92,6 +112,11 @@ class Application extends Component {
}
+
+
@@ -71,6 +72,7 @@ class Applications extends Component {
);
@@ -109,6 +111,7 @@ Applications.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteApplication: PropTypes.func.isRequired
};
diff --git a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js
index a984299f0..9f5e570c5 100644
--- a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js
+++ b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js
@@ -4,13 +4,20 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteApplication, fetchApplications } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
-import sortByName from 'Utilities/Array/sortByName';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
import Applications from './Applications';
function createMapStateToProps() {
return createSelector(
- createSortedSectionSelector('settings.applications', sortByName),
- (applications) => applications
+ createSortedSectionSelector('settings.applications', sortByProp('name')),
+ createTagsSelector(),
+ (applications, tagList) => {
+ return {
+ ...applications,
+ tagList
+ };
+ }
);
}
diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js
index 2fdd57161..00e30cdb7 100644
--- a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js
+++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js
@@ -14,13 +14,29 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
+import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import styles from './EditApplicationModalContent.css';
const syncLevelOptions = [
- { key: 'disabled', value: translate('Disabled') },
- { key: 'addOnly', value: translate('AddRemoveOnly') },
- { key: 'fullSync', value: translate('FullSync') }
+ {
+ key: 'disabled',
+ get value() {
+ return translate('Disabled');
+ }
+ },
+ {
+ key: 'addOnly',
+ get value() {
+ return translate('AddRemoveOnly');
+ }
+ },
+ {
+ key: 'fullSync',
+ get value() {
+ return translate('FullSync');
+ }
+ }
];
function EditApplicationModalContent(props) {
@@ -38,11 +54,13 @@ function EditApplicationModalContent(props) {
onSavePress,
onTestPress,
onDeleteApplicationPress,
+ onAdvancedSettingsPress,
...otherProps
} = props;
const {
id,
+ implementationName,
name,
syncLevel,
tags,
@@ -53,7 +71,7 @@ function EditApplicationModalContent(props) {
return (
- {`${id ? translate('Edit') : translate('Add')} ${translate('Application')}`}
+ {id ? translate('EditApplicationImplementation', { implementationName }) : translate('AddApplicationImplementation', { implementationName })}
@@ -100,7 +118,10 @@ function EditApplicationModalContent(props) {
type={inputTypes.SELECT}
values={syncLevelOptions}
name="syncLevel"
- helpText={`${translate('SyncLevelAddRemove')}
${translate('SyncLevelFull')}`}
+ helpTexts={[
+ translate('SyncLevelAddRemove'),
+ translate('SyncLevelFull')
+ ]}
{...syncLevel}
onChange={onInputChange}
/>
@@ -112,7 +133,8 @@ function EditApplicationModalContent(props) {
@@ -149,6 +171,12 @@ function EditApplicationModalContent(props) {
}
+
+
{
+ this.props.toggleAdvancedSettings();
+ };
+
//
// Render
@@ -67,6 +78,7 @@ class EditApplicationModalContentConnector extends Component {
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
+ onAdvancedSettingsPress={this.onAdvancedSettingsPress}
/>
);
}
@@ -82,7 +94,8 @@ EditApplicationModalContentConnector.propTypes = {
setApplicationFieldValue: PropTypes.func,
saveApplication: PropTypes.func,
testApplication: PropTypes.func,
- onModalClose: PropTypes.func.isRequired
+ onModalClose: PropTypes.func.isRequired,
+ toggleAdvancedSettings: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditApplicationModalContentConnector);
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx
new file mode 100644
index 000000000..1b99f543a
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ManageApplicationsEditModalContent from './ManageApplicationsEditModalContent';
+
+interface ManageApplicationsEditModalProps {
+ isOpen: boolean;
+ applicationIds: number[];
+ onSavePress(payload: object): void;
+ onModalClose(): void;
+}
+
+function ManageApplicationsEditModal(props: ManageApplicationsEditModalProps) {
+ const { isOpen, applicationIds, onSavePress, onModalClose } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default ManageApplicationsEditModal;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css
new file mode 100644
index 000000000..ea406894e
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css
@@ -0,0 +1,16 @@
+.modalFooter {
+ composes: modalFooter from '~Components/Modal/ModalFooter.css';
+
+ justify-content: space-between;
+}
+
+.selected {
+ font-weight: bold;
+}
+
+@media only screen and (max-width: $breakpointExtraSmall) {
+ .modalFooter {
+ flex-direction: column;
+ gap: 10px;
+ }
+}
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts
new file mode 100644
index 000000000..cbf2d6328
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'modalFooter': string;
+ 'selected': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx
new file mode 100644
index 000000000..57e88a4fe
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx
@@ -0,0 +1,130 @@
+import React, { useCallback, useState } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+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 { inputTypes } from 'Helpers/Props';
+import { ApplicationSyncLevel } from 'typings/Application';
+import translate from 'Utilities/String/translate';
+import styles from './ManageApplicationsEditModalContent.css';
+
+interface SavePayload {
+ syncLevel?: ApplicationSyncLevel;
+}
+
+interface ManageApplicationsEditModalContentProps {
+ applicationIds: number[];
+ onSavePress(payload: object): void;
+ onModalClose(): void;
+}
+
+const NO_CHANGE = 'noChange';
+
+const syncLevelOptions = [
+ {
+ key: NO_CHANGE,
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: true,
+ },
+ {
+ key: ApplicationSyncLevel.Disabled,
+ get value() {
+ return translate('Disabled');
+ },
+ },
+ {
+ key: ApplicationSyncLevel.AddOnly,
+ get value() {
+ return translate('AddRemoveOnly');
+ },
+ },
+ {
+ key: ApplicationSyncLevel.FullSync,
+ get value() {
+ return translate('FullSync');
+ },
+ },
+];
+
+function ManageApplicationsEditModalContent(
+ props: ManageApplicationsEditModalContentProps
+) {
+ const { applicationIds, onSavePress, onModalClose } = props;
+
+ const [syncLevel, setSyncLevel] = useState(NO_CHANGE);
+
+ const save = useCallback(() => {
+ let hasChanges = false;
+ const payload: SavePayload = {};
+
+ if (syncLevel !== NO_CHANGE) {
+ hasChanges = true;
+ payload.syncLevel = syncLevel as ApplicationSyncLevel;
+ }
+
+ if (hasChanges) {
+ onSavePress(payload);
+ }
+
+ onModalClose();
+ }, [syncLevel, onSavePress, onModalClose]);
+
+ const onInputChange = useCallback(
+ ({ name, value }: { name: string; value: string }) => {
+ switch (name) {
+ case 'syncLevel':
+ setSyncLevel(value);
+ break;
+ default:
+ console.warn(`EditApplicationsModalContent Unknown Input: '${name}'`);
+ }
+ },
+ []
+ );
+
+ const selectedCount = applicationIds.length;
+
+ return (
+
+ {translate('EditSelectedApplications')}
+
+
+
+ {translate('SyncLevel')}
+
+
+
+
+
+
+
+ {translate('CountApplicationsSelected', { count: selectedCount })}
+
+
+
+ {translate('Cancel')}
+
+ {translate('ApplyChanges')}
+
+
+
+ );
+}
+
+export default ManageApplicationsEditModalContent;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx
new file mode 100644
index 000000000..e0bce2138
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ManageApplicationsModalContent from './ManageApplicationsModalContent';
+
+interface ManageApplicationsModalProps {
+ isOpen: boolean;
+ onModalClose(): void;
+}
+
+function ManageApplicationsModal(props: ManageApplicationsModalProps) {
+ const { isOpen, onModalClose } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default ManageApplicationsModal;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css
new file mode 100644
index 000000000..c106388ab
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css
@@ -0,0 +1,16 @@
+.leftButtons,
+.rightButtons {
+ display: flex;
+ flex: 1 0 50%;
+ flex-wrap: wrap;
+}
+
+.rightButtons {
+ justify-content: flex-end;
+}
+
+.deleteButton {
+ composes: button from '~Components/Link/Button.css';
+
+ margin-right: 10px;
+}
\ No newline at end of file
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts
new file mode 100644
index 000000000..7b392fff9
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+ 'leftButtons': string;
+ 'rightButtons': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx
new file mode 100644
index 000000000..bb81729f3
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx
@@ -0,0 +1,298 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { ApplicationAppState } from 'App/State/SettingsAppState';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+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 Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import useSelectState from 'Helpers/Hooks/useSelectState';
+import { kinds } from 'Helpers/Props';
+import SortDirection from 'Helpers/Props/SortDirection';
+import {
+ bulkDeleteApplications,
+ bulkEditApplications,
+ setManageApplicationsSort,
+} from 'Store/Actions/settingsActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { SelectStateInputProps } from 'typings/props';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import ManageApplicationsEditModal from './Edit/ManageApplicationsEditModal';
+import ManageApplicationsModalRow from './ManageApplicationsModalRow';
+import TagsModal from './Tags/TagsModal';
+import styles from './ManageApplicationsModalContent.css';
+
+// TODO: This feels janky to do, but not sure of a better way currently
+type OnSelectedChangeCallback = React.ComponentProps<
+ typeof ManageApplicationsModalRow
+>['onSelectedChange'];
+
+const COLUMNS = [
+ {
+ name: 'name',
+ label: () => translate('Name'),
+ isSortable: true,
+ isVisible: true,
+ },
+ {
+ name: 'implementation',
+ label: () => translate('Implementation'),
+ isSortable: true,
+ isVisible: true,
+ },
+ {
+ name: 'syncLevel',
+ label: () => translate('SyncLevel'),
+ isSortable: true,
+ isVisible: true,
+ },
+ {
+ name: 'tags',
+ label: () => translate('Tags'),
+ isSortable: true,
+ isVisible: true,
+ },
+];
+
+interface ManageApplicationsModalContentProps {
+ onModalClose(): void;
+ sortKey?: string;
+ sortDirection?: SortDirection;
+}
+
+function ManageApplicationsModalContent(
+ props: ManageApplicationsModalContentProps
+) {
+ const { onModalClose } = props;
+
+ const {
+ isFetching,
+ isPopulated,
+ isDeleting,
+ isSaving,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ }: ApplicationAppState = useSelector(
+ createClientSideCollectionSelector('settings.applications')
+ );
+ const dispatch = useDispatch();
+
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
+ const [isSavingTags, setIsSavingTags] = useState(false);
+
+ const [selectState, setSelectState] = useSelectState();
+
+ const { allSelected, allUnselected, selectedState } = selectState;
+
+ const selectedIds: number[] = useMemo(() => {
+ return getSelectedIds(selectedState);
+ }, [selectedState]);
+
+ const selectedCount = selectedIds.length;
+
+ const onSortPress = useCallback(
+ (value: string) => {
+ dispatch(setManageApplicationsSort({ sortKey: value }));
+ },
+ [dispatch]
+ );
+
+ const onDeletePress = useCallback(() => {
+ setIsDeleteModalOpen(true);
+ }, [setIsDeleteModalOpen]);
+
+ const onDeleteModalClose = useCallback(() => {
+ setIsDeleteModalOpen(false);
+ }, [setIsDeleteModalOpen]);
+
+ const onEditPress = useCallback(() => {
+ setIsEditModalOpen(true);
+ }, [setIsEditModalOpen]);
+
+ const onEditModalClose = useCallback(() => {
+ setIsEditModalOpen(false);
+ }, [setIsEditModalOpen]);
+
+ const onConfirmDelete = useCallback(() => {
+ dispatch(bulkDeleteApplications({ ids: selectedIds }));
+ setIsDeleteModalOpen(false);
+ }, [selectedIds, dispatch]);
+
+ const onSavePress = useCallback(
+ (payload: object) => {
+ setIsEditModalOpen(false);
+
+ dispatch(
+ bulkEditApplications({
+ ids: selectedIds,
+ ...payload,
+ })
+ );
+ },
+ [selectedIds, dispatch]
+ );
+
+ const onTagsPress = useCallback(() => {
+ setIsTagsModalOpen(true);
+ }, [setIsTagsModalOpen]);
+
+ const onTagsModalClose = useCallback(() => {
+ setIsTagsModalOpen(false);
+ }, [setIsTagsModalOpen]);
+
+ const onApplyTagsPress = useCallback(
+ (tags: number[], applyTags: string) => {
+ setIsSavingTags(true);
+ setIsTagsModalOpen(false);
+
+ dispatch(
+ bulkEditApplications({
+ ids: selectedIds,
+ tags,
+ applyTags,
+ })
+ );
+ },
+ [selectedIds, dispatch]
+ );
+
+ const onSelectAllChange = useCallback(
+ ({ value }: SelectStateInputProps) => {
+ setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
+ },
+ [items, setSelectState]
+ );
+
+ const onSelectedChange = useCallback(
+ ({ id, value, shiftKey = false }) => {
+ setSelectState({
+ type: 'toggleSelected',
+ items,
+ id,
+ isSelected: value,
+ shiftKey,
+ });
+ },
+ [items, setSelectState]
+ );
+
+ const errorMessage = getErrorMessage(
+ error,
+ 'Unable to load download clients.'
+ );
+ const anySelected = selectedCount > 0;
+
+ return (
+
+ {translate('ManageApplications')}
+
+ {isFetching ? : null}
+
+ {error ? {errorMessage}
: null}
+
+ {isPopulated && !error && !items.length && (
+ {translate('NoApplicationsFound')}
+ )}
+
+ {isPopulated && !!items.length && !isFetching && !isFetching ? (
+
+
+ {items.map((item) => {
+ return (
+
+ );
+ })}
+
+
+ ) : null}
+
+
+
+
+
+ {translate('Delete')}
+
+
+
+ {translate('Edit')}
+
+
+
+ {translate('SetTags')}
+
+
+
+ {translate('Close')}
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ManageApplicationsModalContent;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css
new file mode 100644
index 000000000..8c126288c
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css
@@ -0,0 +1,8 @@
+.name,
+.syncLevel,
+.tags,
+.implementation {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts
new file mode 100644
index 000000000..cd3e47aae
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'implementation': string;
+ 'name': string;
+ 'syncLevel': string;
+ 'tags': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx
new file mode 100644
index 000000000..f41997f54
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx
@@ -0,0 +1,82 @@
+import React, { useCallback } from 'react';
+import Label from 'Components/Label';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import Column from 'Components/Table/Column';
+import TableRow from 'Components/Table/TableRow';
+import TagListConnector from 'Components/TagListConnector';
+import { kinds } from 'Helpers/Props';
+import { ApplicationSyncLevel } from 'typings/Application';
+import { SelectStateInputProps } from 'typings/props';
+import translate from 'Utilities/String/translate';
+import styles from './ManageApplicationsModalRow.css';
+
+interface ManageApplicationsModalRowProps {
+ id: number;
+ name: string;
+ syncLevel: string;
+ implementation: string;
+ tags: number[];
+ columns: Column[];
+ isSelected?: boolean;
+ onSelectedChange(result: SelectStateInputProps): void;
+}
+
+function ManageApplicationsModalRow(props: ManageApplicationsModalRowProps) {
+ const {
+ id,
+ isSelected,
+ name,
+ syncLevel,
+ implementation,
+ tags,
+ onSelectedChange,
+ } = props;
+
+ const onSelectedChangeWrapper = useCallback(
+ (result: SelectStateInputProps) => {
+ onSelectedChange({
+ ...result,
+ });
+ },
+ [onSelectedChange]
+ );
+
+ return (
+
+
+
+ {name}
+
+
+ {implementation}
+
+
+
+ {syncLevel === ApplicationSyncLevel.AddOnly && (
+
+ )}
+
+ {syncLevel === ApplicationSyncLevel.FullSync && (
+
+ )}
+
+ {syncLevel === ApplicationSyncLevel.Disabled && (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default ManageApplicationsModalRow;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx
new file mode 100644
index 000000000..2e24d60e8
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TagsModalContent from './TagsModalContent';
+
+interface TagsModalProps {
+ isOpen: boolean;
+ ids: number[];
+ onApplyTagsPress: (tags: number[], applyTags: string) => void;
+ onModalClose: () => void;
+}
+
+function TagsModal(props: TagsModalProps) {
+ const { isOpen, onModalClose, ...otherProps } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default TagsModal;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css
new file mode 100644
index 000000000..63be9aadd
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css
@@ -0,0 +1,12 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.result {
+ padding-top: 4px;
+}
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts
new file mode 100644
index 000000000..9b4321dcc
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'message': string;
+ 'renameIcon': string;
+ 'result': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx
new file mode 100644
index 000000000..11900311e
--- /dev/null
+++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx
@@ -0,0 +1,183 @@
+import { uniq } from 'lodash';
+import React, { useCallback, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import { ApplicationAppState } from 'App/State/SettingsAppState';
+import { Tag } from 'App/State/TagsAppState';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Label from 'Components/Label';
+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 { inputTypes, kinds, sizes } from 'Helpers/Props';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import Application from 'typings/Application';
+import translate from 'Utilities/String/translate';
+import styles from './TagsModalContent.css';
+
+interface TagsModalContentProps {
+ ids: number[];
+ onApplyTagsPress: (tags: number[], applyTags: string) => void;
+ onModalClose: () => void;
+}
+
+function TagsModalContent(props: TagsModalContentProps) {
+ const { ids, onModalClose, onApplyTagsPress } = props;
+
+ const allApplications: ApplicationAppState = useSelector(
+ (state: AppState) => state.settings.applications
+ );
+ const tagList: Tag[] = useSelector(createTagsSelector());
+
+ const [tags, setTags] = useState([]);
+ const [applyTags, setApplyTags] = useState('add');
+
+ const applicationsTags = useMemo(() => {
+ const tags = ids.reduce((acc: number[], id) => {
+ const s = allApplications.items.find((s: Application) => s.id === id);
+
+ if (s) {
+ acc.push(...s.tags);
+ }
+
+ return acc;
+ }, []);
+
+ return uniq(tags);
+ }, [ids, allApplications]);
+
+ const onTagsChange = useCallback(
+ ({ value }: { value: number[] }) => {
+ setTags(value);
+ },
+ [setTags]
+ );
+
+ const onApplyTagsChange = useCallback(
+ ({ value }: { value: string }) => {
+ setApplyTags(value);
+ },
+ [setApplyTags]
+ );
+
+ const onApplyPress = useCallback(() => {
+ onApplyTagsPress(tags, applyTags);
+ }, [tags, applyTags, onApplyTagsPress]);
+
+ const applyTagsOptions = [
+ { key: 'add', value: translate('Add') },
+ { key: 'remove', value: translate('Remove') },
+ { key: 'replace', value: translate('Replace') },
+ ];
+
+ return (
+
+ {translate('Tags')}
+
+
+
+
+
+
+ {translate('Cancel')}
+
+
+ {translate('Apply')}
+
+
+
+ );
+}
+
+export default TagsModalContent;
diff --git a/frontend/src/Settings/Development/DevelopmentSettings.js b/frontend/src/Settings/Development/DevelopmentSettings.js
index 7c25e2c68..128055ba8 100644
--- a/frontend/src/Settings/Development/DevelopmentSettings.js
+++ b/frontend/src/Settings/Development/DevelopmentSettings.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@@ -8,7 +9,7 @@ import FormLabel from 'Components/Form/FormLabel';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
-import { inputTypes } from 'Helpers/Props';
+import { inputTypes, kinds } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
@@ -49,9 +50,9 @@ class DevelopmentSettings extends Component {
{
!isFetching && error &&
-
+
{translate('UnableToLoadDevelopmentSettings')}
-
+
}
{
diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
index 3e060aa5d..5bd284b45 100644
--- a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
@@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
+import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
class DownloadClientSettings extends Component {
@@ -21,7 +22,8 @@ class DownloadClientSettings extends Component {
this.state = {
isSaving: false,
- hasPendingChanges: false
+ hasPendingChanges: false,
+ isManageDownloadClientsOpen: false
};
}
@@ -36,6 +38,14 @@ class DownloadClientSettings extends Component {
this.setState(payload);
};
+ onManageDownloadClientsPress = () => {
+ this.setState({ isManageDownloadClientsOpen: true });
+ };
+
+ onManageDownloadClientsModalClose = () => {
+ this.setState({ isManageDownloadClientsOpen: false });
+ };
+
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
@@ -53,7 +63,8 @@ class DownloadClientSettings extends Component {
const {
isSaving,
- hasPendingChanges
+ hasPendingChanges,
+ isManageDownloadClientsOpen
} = this.state;
return (
@@ -71,6 +82,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients}
/>
+
+
}
onSavePress={this.onSavePress}
@@ -78,6 +95,11 @@ class DownloadClientSettings extends Component {
+
+
);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js
index 71c51849c..e79f615ea 100644
--- a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js
@@ -35,7 +35,7 @@ function AddCategoryModalContent(props) {
return (
- {`${id ? 'Edit' : 'Add'} Category`}
+ {id ? translate('EditCategory') : translate('AddCategory')}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js
index 6e0a25a2d..1d1f61469 100644
--- a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js
@@ -88,7 +88,7 @@ class Category extends Component {
message={
- {translate('AreYouSureYouWantToDeleteCategory', [name])}
+ {translate('AreYouSureYouWantToDeleteCategory')}
}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
index 8cea557a9..13b24343d 100644
--- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
@@ -89,7 +89,7 @@ class DownloadClient extends Component {
kind={kinds.DISABLED}
outline={true}
>
- {translate('PrioritySettings', [priority])}
+ {translate('Priority')}: {priority}
}
@@ -105,7 +105,7 @@ class DownloadClient extends Component {
isOpen={this.state.isDeleteDownloadClientModalOpen}
kind={kinds.DANGER}
title={translate('DeleteDownloadClient')}
- message={translate('DeleteDownloadClientMessageText', [name])}
+ message={translate('DeleteDownloadClientMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteDownloadClient}
onCancel={this.onDeleteDownloadClientModalClose}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
index 640d56a89..51f390d4f 100644
--- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
@@ -1,10 +1,11 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import Alert from 'Components/Alert';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
-import { icons } from 'Helpers/Props';
+import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddDownloadClientModal from './AddDownloadClientModal';
import DownloadClient from './DownloadClient';
@@ -59,48 +60,59 @@ class DownloadClients extends Component {
} = this.state;
return (
-
+
+
+
+
{
- onHealthIssue.value &&
+ (onHealthIssue.value || onHealthRestored.value) &&
);
@@ -109,6 +111,7 @@ Notifications.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteNotification: PropTypes.func.isRequired
};
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
index 83ee6c697..6351c6f8a 100644
--- a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
@@ -4,13 +4,20 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteNotification, fetchNotifications } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
-import sortByName from 'Utilities/Array/sortByName';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
import Notifications from './Notifications';
function createMapStateToProps() {
return createSelector(
- createSortedSectionSelector('settings.notifications', sortByName),
- (notifications) => notifications
+ createSortedSectionSelector('settings.notifications', sortByProp('name')),
+ createTagsSelector(),
+ (notifications, tagList) => {
+ return {
+ ...notifications,
+ tagList
+ };
+ }
);
}
diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js
index 4cb83e8f6..213445c65 100644
--- a/frontend/src/Settings/PendingChangesModal.js
+++ b/frontend/src/Settings/PendingChangesModal.js
@@ -15,12 +15,17 @@ function PendingChangesModal(props) {
isOpen,
onConfirm,
onCancel,
- bindShortcut
+ bindShortcut,
+ unbindShortcut
} = props;
useEffect(() => {
- bindShortcut('enter', onConfirm);
- }, [bindShortcut, onConfirm]);
+ if (isOpen) {
+ bindShortcut('enter', onConfirm);
+
+ return () => unbindShortcut('enter', onConfirm);
+ }
+ }, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
return (
- {translate('RSS')}
+ {translate('Rss')}
}
@@ -130,7 +130,7 @@ class AppProfile extends Component {
isOpen={this.state.isDeleteAppProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteAppProfile')}
- message={translate('AppProfileDeleteConfirm', [name])}
+ message={translate('DeleteAppProfileMessageText', { name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteAppProfile}
diff --git a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js
index a150655a6..02bf845df 100644
--- a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js
+++ b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js
@@ -4,12 +4,12 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cloneAppProfile, deleteAppProfile, fetchAppProfiles } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
-import sortByName from 'Utilities/Array/sortByName';
+import sortByProp from 'Utilities/Array/sortByProp';
import AppProfiles from './AppProfiles';
function createMapStateToProps() {
return createSelector(
- createSortedSectionSelector('settings.appProfiles', sortByName),
+ createSortedSectionSelector('settings.appProfiles', sortByProp('name')),
(appProfiles) => appProfiles
);
}
diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js
index aace8e039..ac67c77f2 100644
--- a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js
+++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js
@@ -97,20 +97,6 @@ class EditAppProfileModalContent extends Component {
/>
-
-
- {translate('EnableInteractiveSearch')}
-
-
-
-
-
{translate('EnableAutomaticSearch')}
@@ -125,6 +111,20 @@ class EditAppProfileModalContent extends Component {
/>
+
+
+ {translate('EnableInteractiveSearch')}
+
+
+
+
+
{translate('MinimumSeeders')}
diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js
index 607a67543..afeb3863a 100644
--- a/frontend/src/Settings/Tags/Tag.js
+++ b/frontend/src/Settings/Tags/Tag.js
@@ -137,7 +137,7 @@ class Tag extends Component {
isOpen={isDeleteTagModalOpen}
kind={kinds.DANGER}
title={translate('DeleteTag')}
- message={translate('DeleteTagMessageText', [label])}
+ message={translate('DeleteTagMessageText', { label })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteTag}
onCancel={this.onDeleteTagModalClose}
diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js
index 4f311e984..1f3de2034 100644
--- a/frontend/src/Settings/Tags/TagsConnector.js
+++ b/frontend/src/Settings/Tags/TagsConnector.js
@@ -3,12 +3,14 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchApplications, fetchIndexerProxies, fetchNotifications } from 'Store/Actions/settingsActions';
-import { fetchTagDetails } from 'Store/Actions/tagActions';
+import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
import Tags from './Tags';
function createMapStateToProps() {
return createSelector(
- (state) => state.tags,
+ createSortedSectionSelector('tags', sortByProp('label')),
(tags) => {
const isFetching = tags.isFetching || tags.details.isFetching;
const error = tags.error || tags.details.error;
@@ -25,6 +27,7 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
+ dispatchFetchTags: fetchTags,
dispatchFetchTagDetails: fetchTagDetails,
dispatchFetchNotifications: fetchNotifications,
dispatchFetchIndexerProxies: fetchIndexerProxies,
@@ -38,12 +41,14 @@ class MetadatasConnector extends Component {
componentDidMount() {
const {
+ dispatchFetchTags,
dispatchFetchTagDetails,
dispatchFetchNotifications,
dispatchFetchIndexerProxies,
dispatchFetchApplications
} = this.props;
+ dispatchFetchTags();
dispatchFetchTagDetails();
dispatchFetchNotifications();
dispatchFetchIndexerProxies();
@@ -63,6 +68,7 @@ class MetadatasConnector extends Component {
}
MetadatasConnector.propTypes = {
+ dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchTagDetails: PropTypes.func.isRequired,
dispatchFetchNotifications: PropTypes.func.isRequired,
dispatchFetchIndexerProxies: PropTypes.func.isRequired,
diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js
index 83443cd72..d156f4ff3 100644
--- a/frontend/src/Settings/UI/UISettings.js
+++ b/frontend/src/Settings/UI/UISettings.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@@ -8,7 +9,7 @@ import FormLabel from 'Components/Form/FormLabel';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
-import { inputTypes } from 'Helpers/Props';
+import { inputTypes, kinds } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import themes from 'Styles/Themes';
import titleCase from 'Utilities/String/titleCase';
@@ -20,19 +21,19 @@ export const firstDayOfWeekOptions = [
];
export const weekColumnOptions = [
- { key: 'ddd M/D', value: 'Tue 3/25' },
- { key: 'ddd MM/DD', value: 'Tue 03/25' },
- { key: 'ddd D/M', value: 'Tue 25/3' },
- { key: 'ddd DD/MM', value: 'Tue 25/03' }
+ { key: 'ddd M/D', value: 'Tue 3/25', hint: 'ddd M/D' },
+ { key: 'ddd MM/DD', value: 'Tue 03/25', hint: 'ddd MM/DD' },
+ { key: 'ddd D/M', value: 'Tue 25/3', hint: 'ddd D/M' },
+ { key: 'ddd DD/MM', value: 'Tue 25/03', hint: 'ddd DD/MM' }
];
const shortDateFormatOptions = [
- { key: 'MMM D YYYY', value: 'Mar 25 2014' },
- { key: 'DD MMM YYYY', value: '25 Mar 2014' },
- { key: 'MM/D/YYYY', value: '03/25/2014' },
- { key: 'MM/DD/YYYY', value: '03/25/2014' },
- { key: 'DD/MM/YYYY', value: '25/03/2014' },
- { key: 'YYYY-MM-DD', value: '2014-03-25' }
+ { key: 'MMM D YYYY', value: 'Mar 25 2014', hint: 'MMM D YYYY' },
+ { key: 'DD MMM YYYY', value: '25 Mar 2014', hint: 'DD MMM YYYY' },
+ { key: 'MM/D/YYYY', value: '03/25/2014', hint: 'MM/D/YYYY' },
+ { key: 'MM/DD/YYYY', value: '03/25/2014', hint: 'MM/DD/YYYY' },
+ { key: 'DD/MM/YYYY', value: '25/03/2014', hint: 'DD/MM/YYYY' },
+ { key: 'YYYY-MM-DD', value: '2014-03-25', hint: 'YYYY-MM-DD' }
];
const longDateFormatOptions = [
@@ -80,9 +81,9 @@ class UISettings extends Component {
{
!isFetching && error &&
-
+
{translate('UnableToLoadUISettings')}
-
+
}
{
@@ -146,7 +147,7 @@ class UISettings extends Component {
language.key === settings.uiLanguage.value) ?
+ settings.uiLanguage.errors :
+ [
+ ...settings.uiLanguage.errors,
+ { message: translate('InvalidUILanguage') }
+ ]}
/>
diff --git a/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js
new file mode 100644
index 000000000..f174dae54
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js
@@ -0,0 +1,54 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, updateItem } from '../baseActions';
+
+function createBulkEditItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+
+ dispatch(set({ section, isSaving: true }));
+
+ const ajaxOptions = {
+ url: `${url}`,
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ };
+
+ const promise = createAjaxRequest(ajaxOptions).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ }),
+
+ ...data.map((provider) => {
+
+ const {
+ ...propsToUpdate
+ } = provider;
+
+ return updateItem({
+ id: provider.id,
+ section,
+ ...propsToUpdate
+ });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createBulkEditItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js
new file mode 100644
index 000000000..3293ff1b5
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js
@@ -0,0 +1,48 @@
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { removeItem, set } from '../baseActions';
+
+function createBulkRemoveItemHandler(section, url) {
+ return function(getState, payload, dispatch) {
+ const {
+ ids
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}`,
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ };
+
+ const promise = createAjaxRequest(ajaxOptions).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }),
+
+ ...ids.map((id) => {
+ return removeItem({ section, id });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+
+ return promise;
+ };
+}
+
+export default createBulkRemoveItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
index a80ee1e45..f5ef10a4d 100644
--- a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -6,6 +6,8 @@ import getSectionState from 'Utilities/State/getSectionState';
import { set, updateServerSideCollection } from '../baseActions';
function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) {
+ const [baseSection] = section.split('.');
+
return function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
@@ -25,10 +27,13 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter
const {
selectedFilterKey,
- filters,
- customFilters
+ filters
} = sectionState;
+ const customFilters = getState().customFilters.items.filter((customFilter) => {
+ return customFilter.type === section || customFilter.type === baseSection;
+ });
+
const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
selectedFilters.forEach((filter) => {
@@ -37,7 +42,8 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter
const promise = createAjaxRequest({
url,
- data
+ data,
+ traditional: true
}).request;
promise.done((response) => {
diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
index 5761655d2..1cccf1666 100644
--- a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -32,9 +32,9 @@ function createSaveProviderHandler(section, url, options = {}) {
const params = { ...queryParams };
// If the user is re-saving the same provider without changes
- // force it to be saved. Only applies to editing existing providers.
+ // force it to be saved.
- if (id && _.isEqual(saveData, lastSaveData)) {
+ if (_.isEqual(saveData, lastSaveData)) {
params.forceSave = true;
}
diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
index ca26883fb..e35157dbd 100644
--- a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
+++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
@@ -1,8 +1,11 @@
+import $ from 'jquery';
+import _ from 'lodash';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getProviderState from 'Utilities/State/getProviderState';
import { set } from '../baseActions';
const abortCurrentRequests = {};
+let lastTestData = null;
export function createCancelTestProviderHandler(section) {
return function(getState, payload, dispatch) {
@@ -17,10 +20,25 @@ function createTestProviderHandler(section, url) {
return function(getState, payload, dispatch) {
dispatch(set({ section, isTesting: true }));
- const testData = getProviderState(payload, getState, section);
+ const {
+ queryParams = {},
+ ...otherPayload
+ } = payload;
+
+ const testData = getProviderState({ ...otherPayload }, getState, section);
+ const params = { ...queryParams };
+
+ // If the user is re-testing the same provider without changes
+ // force it to be tested.
+
+ if (_.isEqual(testData, lastTestData)) {
+ params.forceTest = true;
+ }
+
+ lastTestData = testData;
const ajaxOptions = {
- url: `${url}/test`,
+ url: `${url}/test?${$.param(params, true)}`,
method: 'POST',
contentType: 'application/json',
dataType: 'json',
@@ -32,6 +50,8 @@ function createTestProviderHandler(section, url) {
abortCurrentRequests[section] = abortRequest;
request.done((data) => {
+ lastTestData = null;
+
dispatch(set({
section,
isTesting: false,
diff --git a/frontend/src/Store/Actions/Settings/appProfiles.js b/frontend/src/Store/Actions/Settings/appProfiles.js
index 70f8a8961..92a48e0b8 100644
--- a/frontend/src/Store/Actions/Settings/appProfiles.js
+++ b/frontend/src/Store/Actions/Settings/appProfiles.js
@@ -7,6 +7,7 @@ import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/create
import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
+import translate from 'Utilities/String/translate';
//
// Variables
@@ -52,14 +53,14 @@ export default {
isFetching: false,
isPopulated: false,
error: null,
- isDeleting: false,
- deleteError: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: {},
isSaving: false,
saveError: null,
+ isDeleting: false,
+ deleteError: null,
items: [],
pendingChanges: {}
},
@@ -87,7 +88,7 @@ export default {
const pendingChanges = { ...item, id: 0 };
delete pendingChanges.id;
- pendingChanges.name = `${pendingChanges.name} - Copy`;
+ pendingChanges.name = translate('DefaultNameCopiedProfile', { name: pendingChanges.name });
newState.pendingChanges = pendingChanges;
return updateSectionState(state, section, newState);
diff --git a/frontend/src/Store/Actions/Settings/applications.js b/frontend/src/Store/Actions/Settings/applications.js
index a670732e0..53a008b0c 100644
--- a/frontend/src/Store/Actions/Settings/applications.js
+++ b/frontend/src/Store/Actions/Settings/applications.js
@@ -1,10 +1,14 @@
import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
@@ -28,7 +32,10 @@ export const CANCEL_SAVE_APPLICATION = 'settings/applications/cancelSaveApplicat
export const DELETE_APPLICATION = 'settings/applications/deleteApplication';
export const TEST_APPLICATION = 'settings/applications/testApplication';
export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication';
-export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications';
+export const TEST_ALL_APPLICATIONS = 'settings/applications/testAllApplications';
+export const BULK_EDIT_APPLICATIONS = 'settings/applications/bulkEditApplications';
+export const BULK_DELETE_APPLICATIONS = 'settings/applications/bulkDeleteApplications';
+export const SET_MANAGE_APPLICATIONS_SORT = 'settings/applications/setManageApplicationsSort';
//
// Action Creators
@@ -43,6 +50,9 @@ export const deleteApplication = createThunk(DELETE_APPLICATION);
export const testApplication = createThunk(TEST_APPLICATION);
export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION);
export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS);
+export const bulkEditApplications = createThunk(BULK_EDIT_APPLICATIONS);
+export const bulkDeleteApplications = createThunk(BULK_DELETE_APPLICATIONS);
+export const setManageApplicationsSort = createAction(SET_MANAGE_APPLICATIONS_SORT);
export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => {
return {
@@ -77,10 +87,19 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
+ isDeleting: false,
+ deleteError: null,
isTesting: false,
isTestingAll: false,
items: [],
- pendingChanges: {}
+ pendingChanges: {},
+ sortKey: 'name',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ name: function(item) {
+ return item.name.toLowerCase();
+ }
+ }
},
//
@@ -95,7 +114,9 @@ export default {
[DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'),
[TEST_APPLICATION]: createTestProviderHandler(section, '/applications'),
[CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section),
- [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications')
+ [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications'),
+ [BULK_EDIT_APPLICATIONS]: createBulkEditItemHandler(section, '/applications/bulk'),
+ [BULK_DELETE_APPLICATIONS]: createBulkRemoveItemHandler(section, '/applications/bulk')
},
//
@@ -107,14 +128,14 @@ export default {
[SELECT_APPLICATION_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
- selectedSchema.onGrab = selectedSchema.supportsOnGrab;
- selectedSchema.onDownload = selectedSchema.supportsOnDownload;
- selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
- selectedSchema.onRename = selectedSchema.supportsOnRename;
+ selectedSchema.name = selectedSchema.implementationName;
return selectedSchema;
});
- }
+ },
+
+ [SET_MANAGE_APPLICATIONS_SORT]: createSetClientSideCollectionSortReducer(section)
+
}
};
diff --git a/frontend/src/Store/Actions/Settings/downloadClientCategories.js b/frontend/src/Store/Actions/Settings/downloadClientCategories.js
index b9fb04404..38cce33c5 100644
--- a/frontend/src/Store/Actions/Settings/downloadClientCategories.js
+++ b/frontend/src/Store/Actions/Settings/downloadClientCategories.js
@@ -75,6 +75,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
+ isDeleting: false,
+ deleteError: null,
items: [],
pendingChanges: {}
},
diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js
index 7e9292f24..56784d5d0 100644
--- a/frontend/src/Store/Actions/Settings/downloadClients.js
+++ b/frontend/src/Store/Actions/Settings/downloadClients.js
@@ -1,10 +1,14 @@
import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
@@ -30,6 +34,9 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl
export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
+export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
+export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
+export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort';
//
// Action Creators
@@ -44,6 +51,9 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
+export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
+export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
+export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return {
@@ -78,10 +88,19 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
+ isDeleting: false,
+ deleteError: null,
isTesting: false,
isTestingAll: false,
items: [],
- pendingChanges: {}
+ pendingChanges: {},
+ sortKey: 'name',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ name: function(item) {
+ return item.name.toLowerCase();
+ }
+ }
},
//
@@ -120,7 +139,9 @@ export default {
},
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
- [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
+ [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'),
+ [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'),
+ [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk')
},
//
@@ -132,11 +153,15 @@ export default {
[SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = selectedSchema.implementationName;
selectedSchema.enable = true;
return selectedSchema;
});
- }
+ },
+
+ [SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section)
+
}
};
diff --git a/frontend/src/Store/Actions/Settings/indexerProxies.js b/frontend/src/Store/Actions/Settings/indexerProxies.js
index 6ba5c731b..6c07540be 100644
--- a/frontend/src/Store/Actions/Settings/indexerProxies.js
+++ b/frontend/src/Store/Actions/Settings/indexerProxies.js
@@ -74,6 +74,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
+ isDeleting: false,
+ deleteError: null,
isTesting: false,
items: [],
pendingChanges: {}
@@ -102,6 +104,8 @@ export default {
[SELECT_INDEXER_PROXY_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = selectedSchema.implementationName;
+
return selectedSchema;
});
}
diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js
index 3242cef4b..28346e9a6 100644
--- a/frontend/src/Store/Actions/Settings/notifications.js
+++ b/frontend/src/Store/Actions/Settings/notifications.js
@@ -74,6 +74,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
+ isDeleting: false,
+ deleteError: null,
isTesting: false,
items: [],
pendingChanges: {}
@@ -102,6 +104,7 @@ export default {
[SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
+ selectedSchema.name = selectedSchema.implementationName;
selectedSchema.onGrab = selectedSchema.supportsOnGrab;
selectedSchema.onApplicationUpdate = selectedSchema.supportsOnApplicationUpdate;
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
index a273c7292..2b779d1b0 100644
--- a/frontend/src/Store/Actions/appActions.js
+++ b/frontend/src/Store/Actions/appActions.js
@@ -4,6 +4,7 @@ import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
+import { fetchTranslations as fetchAppTranslations } from 'Utilities/String/translate';
import createHandleActions from './Creators/createHandleActions';
function getDimensions(width, height) {
@@ -41,7 +42,12 @@ export const defaultState = {
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
- isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
+ isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen,
+ translations: {
+ isFetching: true,
+ isPopulated: false,
+ error: null
+ }
};
//
@@ -53,6 +59,7 @@ export const SAVE_DIMENSIONS = 'app/saveDimensions';
export const SET_VERSION = 'app/setVersion';
export const SET_APP_VALUE = 'app/setAppValue';
export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
+export const FETCH_TRANSLATIONS = 'app/fetchTranslations';
export const PING_SERVER = 'app/pingServer';
@@ -66,6 +73,7 @@ export const setAppValue = createAction(SET_APP_VALUE);
export const showMessage = createAction(SHOW_MESSAGE);
export const hideMessage = createAction(HIDE_MESSAGE);
export const pingServer = createThunk(PING_SERVER);
+export const fetchTranslations = createThunk(FETCH_TRANSLATIONS);
//
// Helpers
@@ -127,6 +135,17 @@ function pingServerAfterTimeout(getState, dispatch) {
export const actionHandlers = handleThunks({
[PING_SERVER]: function(getState, payload, dispatch) {
pingServerAfterTimeout(getState, dispatch);
+ },
+ [FETCH_TRANSLATIONS]: async function(getState, payload, dispatch) {
+ const isFetchingComplete = await fetchAppTranslations();
+
+ dispatch(setAppValue({
+ translations: {
+ isFetching: false,
+ isPopulated: isFetchingComplete,
+ error: isFetchingComplete ? null : 'Failed to load translations from API'
+ }
+ }));
}
});
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
index 7ad498ba0..c324fe227 100644
--- a/frontend/src/Store/Actions/historyActions.js
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -1,5 +1,5 @@
import { createAction } from 'redux-actions';
-import { filterTypes, sortDirections } from 'Helpers/Props';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@@ -30,61 +30,73 @@ export const defaultState = {
columns: [
{
name: 'eventType',
- columnLabel: translate('EventType'),
+ columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false
},
{
name: 'indexer',
- label: translate('Indexer'),
+ label: () => translate('Indexer'),
isSortable: false,
isVisible: true
},
{
name: 'query',
- label: translate('Query'),
+ label: () => translate('Query'),
isSortable: false,
isVisible: true
},
{
name: 'parameters',
- label: translate('Parameters'),
+ label: () => translate('Parameters'),
isSortable: false,
isVisible: true
},
{
name: 'grabTitle',
- label: translate('GrabTitle'),
+ label: () => translate('GrabTitle'),
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'queryType',
+ label: () => translate('QueryType'),
isSortable: false,
isVisible: false
},
{
name: 'categories',
- label: translate('Categories'),
+ label: () => translate('Categories'),
isSortable: false,
isVisible: true
},
{
name: 'date',
- label: translate('Date'),
+ label: () => translate('Date'),
isSortable: true,
isVisible: true
},
{
name: 'source',
- label: translate('Source'),
+ label: () => translate('Source'),
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'host',
+ label: () => translate('Host'),
isSortable: false,
isVisible: false
},
{
name: 'elapsedTime',
- label: translate('ElapsedTime'),
+ label: () => translate('ElapsedTime'),
isSortable: false,
isVisible: true
},
{
name: 'details',
- columnLabel: translate('Details'),
+ columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false
}
@@ -95,12 +107,12 @@ export const defaultState = {
filters: [
{
key: 'all',
- label: translate('All'),
+ label: () => translate('All'),
filters: []
},
{
key: 'releaseGrabbed',
- label: translate('Grabbed'),
+ label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
@@ -111,7 +123,7 @@ export const defaultState = {
},
{
key: 'indexerRss',
- label: translate('IndexerRss'),
+ label: () => translate('IndexerRss'),
filters: [
{
key: 'eventType',
@@ -122,7 +134,7 @@ export const defaultState = {
},
{
key: 'indexerQuery',
- label: translate('IndexerQuery'),
+ label: () => translate('IndexerQuery'),
filters: [
{
key: 'eventType',
@@ -133,7 +145,7 @@ export const defaultState = {
},
{
key: 'indexerAuth',
- label: translate('IndexerAuth'),
+ label: () => translate('IndexerAuth'),
filters: [
{
key: 'eventType',
@@ -144,7 +156,7 @@ export const defaultState = {
},
{
key: 'failed',
- label: translate('Failed'),
+ label: () => translate('Failed'),
filters: [
{
key: 'successful',
@@ -153,6 +165,27 @@ export const defaultState = {
}
]
}
+ ],
+
+ filterBuilderProps: [
+ {
+ name: 'eventType',
+ label: () => translate('EventType'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
+ },
+ {
+ name: 'indexerIds',
+ label: () => translate('Indexer'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.INDEXER
+ },
+ {
+ name: 'successful',
+ label: () => translate('Successful'),
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.BOOL
+ }
]
};
diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js
index 98db37faf..a25144d5a 100644
--- a/frontend/src/Store/Actions/index.js
+++ b/frontend/src/Store/Actions/index.js
@@ -4,6 +4,7 @@ import * as commands from './commandActions';
import * as customFilters from './customFilterActions';
import * as history from './historyActions';
import * as indexers from './indexerActions';
+import * as indexerHistory from './indexerHistoryActions';
import * as indexerIndex from './indexerIndexActions';
import * as indexerStats from './indexerStatsActions';
import * as indexerStatus from './indexerStatusActions';
@@ -28,6 +29,7 @@ export default [
releases,
localization,
indexers,
+ indexerHistory,
indexerIndex,
indexerStats,
indexerStatus,
diff --git a/frontend/src/Store/Actions/indexerActions.js b/frontend/src/Store/Actions/indexerActions.js
index a30d6a73a..e11051c2f 100644
--- a/frontend/src/Store/Actions/indexerActions.js
+++ b/frontend/src/Store/Actions/indexerActions.js
@@ -1,11 +1,15 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
-import { sortDirections } from 'Helpers/Props';
+import { filterTypePredicates, sortDirections } from 'Helpers/Props';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
-import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
+import createSaveProviderHandler, {
+ createCancelSaveProviderHandler
+} from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
-import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
+import createTestProviderHandler, {
+ createCancelTestProviderHandler
+} from 'Store/Actions/Creators/createTestProviderHandler';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk, handleThunks } from 'Store/thunks';
@@ -13,7 +17,10 @@ import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import translate from 'Utilities/String/translate';
+import createBulkEditItemHandler from './Creators/createBulkEditItemHandler';
+import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler';
import createHandleActions from './Creators/createHandleActions';
+import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
//
@@ -29,6 +36,8 @@ export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
+ isDeleting: false,
+ deleteError: null,
selectedSchema: {},
isSaving: false,
saveError: null,
@@ -41,7 +50,7 @@ export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
- sortKey: 'name',
+ sortKey: 'sortName',
sortDirection: sortDirections.ASCENDING,
items: []
}
@@ -50,7 +59,7 @@ export const defaultState = {
export const filters = [
{
key: 'all',
- label: translate('All'),
+ label: () => translate('All'),
filters: []
}
];
@@ -65,15 +74,68 @@ export const filterPredicates = {
item.fields.find((field) => field.name === 'vipExpiration')?.value ?? null;
return dateFilterPredicate(vipExpiration, filterValue, type);
+ },
+
+ categories: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+
+ const { categories = [] } = item.capabilities || {};
+
+ const categoryList = categories
+ .filter((category) => category.id < 100000)
+ .reduce((acc, element) => {
+ acc.push(element.id);
+
+ if (element.subCategories && element.subCategories.length > 0) {
+ element.subCategories.forEach((subCat) => {
+ acc.push(subCat.id);
+ });
+ }
+
+ return acc;
+ }, []);
+
+ return predicate(categoryList, filterValue);
}
};
export const sortPredicates = {
- vipExpiration: function(item) {
- const vipExpiration =
- item.fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
+ status: function({ enable, redirect }) {
+ let result = 0;
- return vipExpiration;
+ if (redirect) {
+ result++;
+ }
+
+ if (enable) {
+ result += 2;
+ }
+
+ return result;
+ },
+
+ vipExpiration: function({ fields = [] }) {
+ return fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
+ },
+
+ minimumSeeders: function({ fields = [] }) {
+ return fields.find((field) => field.name === 'torrentBaseSettings.appMinimumSeeders')?.value ?? undefined;
+ },
+
+ seedRatio: function({ fields = [] }) {
+ return fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')?.value ?? undefined;
+ },
+
+ seedTime: function({ fields = [] }) {
+ return fields.find((field) => field.name === 'torrentBaseSettings.seedTime')?.value ?? undefined;
+ },
+
+ packSeedTime: function({ fields = [] }) {
+ return fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')?.value ?? undefined;
+ },
+
+ preferMagnetUrl: function({ fields = [] }) {
+ return fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl')?.value ?? undefined;
}
};
@@ -84,6 +146,7 @@ export const FETCH_INDEXERS = 'indexers/fetchIndexers';
export const FETCH_INDEXER_SCHEMA = 'indexers/fetchIndexerSchema';
export const SELECT_INDEXER_SCHEMA = 'indexers/selectIndexerSchema';
export const SET_INDEXER_SCHEMA_SORT = 'indexers/setIndexerSchemaSort';
+export const CLEAR_INDEXER_SCHEMA = 'indexers/clearIndexerSchema';
export const CLONE_INDEXER = 'indexers/cloneIndexer';
export const SET_INDEXER_VALUE = 'indexers/setIndexerValue';
export const SET_INDEXER_FIELD_VALUE = 'indexers/setIndexerFieldValue';
@@ -93,6 +156,8 @@ export const DELETE_INDEXER = 'indexers/deleteIndexer';
export const TEST_INDEXER = 'indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers';
+export const BULK_EDIT_INDEXERS = 'indexers/bulkEditIndexers';
+export const BULK_DELETE_INDEXERS = 'indexers/bulkDeleteIndexers';
//
// Action Creators
@@ -101,6 +166,7 @@ export const fetchIndexers = createThunk(FETCH_INDEXERS);
export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA);
export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA);
export const setIndexerSchemaSort = createAction(SET_INDEXER_SCHEMA_SORT);
+export const clearIndexerSchema = createAction(CLEAR_INDEXER_SCHEMA);
export const cloneIndexer = createAction(CLONE_INDEXER);
export const saveIndexer = createThunk(SAVE_INDEXER);
@@ -109,6 +175,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER);
export const testIndexer = createThunk(TEST_INDEXER);
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
+export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
+export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return {
@@ -161,7 +229,9 @@ export const actionHandlers = handleThunks({
[DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
- [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer')
+ [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'),
+ [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'),
+ [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk')
});
//
@@ -174,12 +244,16 @@ export const reducers = createHandleActions({
[SELECT_INDEXER_SCHEMA]: (state, { payload }) => {
return selectSchema(state, payload, (selectedSchema) => {
+ selectedSchema.name = payload.name ?? payload.implementationName;
+ selectedSchema.implementationName = payload.implementationName;
selectedSchema.enable = selectedSchema.supportsRss;
return selectedSchema;
});
},
+ [CLEAR_INDEXER_SCHEMA]: createClearReducer(schemaSection, defaultState),
+
[CLONE_INDEXER]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);
@@ -191,14 +265,20 @@ export const reducers = createHandleActions({
delete selectedSchema.name;
selectedSchema.fields = selectedSchema.fields.map((field) => {
- return { ...field };
+ const newField = { ...field };
+
+ if (newField.privacy === 'apiKey' || newField.privacy === 'password') {
+ newField.value = '';
+ }
+
+ return newField;
});
newState.selectedSchema = selectedSchema;
// Set the name in pendingChanges
newState.pendingChanges = {
- name: `${item.name} - Copy`
+ name: translate('DefaultNameCopiedProfile', { name: item.name })
};
return updateSectionState(state, section, newState);
diff --git a/frontend/src/Store/Actions/indexerHistoryActions.js b/frontend/src/Store/Actions/indexerHistoryActions.js
new file mode 100644
index 000000000..2cec678e1
--- /dev/null
+++ b/frontend/src/Store/Actions/indexerHistoryActions.js
@@ -0,0 +1,81 @@
+import { createAction } from 'redux-actions';
+import { batchActions } from 'redux-batched-actions';
+import { createThunk, handleThunks } from 'Store/thunks';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import { set, update } from './baseActions';
+import createHandleActions from './Creators/createHandleActions';
+
+//
+// Variables
+
+export const section = 'indexerHistory';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_INDEXER_HISTORY = 'indexerHistory/fetchIndexerHistory';
+export const CLEAR_INDEXER_HISTORY = 'indexerHistory/clearIndexerHistory';
+
+//
+// Action Creators
+
+export const fetchIndexerHistory = createThunk(FETCH_INDEXER_HISTORY);
+export const clearIndexerHistory = createAction(CLEAR_INDEXER_HISTORY);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ [FETCH_INDEXER_HISTORY]: function(getState, payload, dispatch) {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = createAjaxRequest({
+ url: '/history/indexer',
+ data: payload
+ }).request;
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ }
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [CLEAR_INDEXER_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/indexerIndexActions.js b/frontend/src/Store/Actions/indexerIndexActions.js
index cb0d0b480..a002d9b41 100644
--- a/frontend/src/Store/Actions/indexerIndexActions.js
+++ b/frontend/src/Store/Actions/indexerIndexActions.js
@@ -1,10 +1,6 @@
import { createAction } from 'redux-actions';
-import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
-import { createThunk, handleThunks } from 'Store/thunks';
-import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate';
-import { removeItem, set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
@@ -36,69 +32,105 @@ export const defaultState = {
columns: [
{
name: 'status',
- columnLabel: translate('ReleaseStatus'),
+ columnLabel: () => translate('IndexerStatus'),
isSortable: true,
isVisible: true,
isModifiable: false
},
+ {
+ name: 'id',
+ columnLabel: () => translate('IndexerId'),
+ label: () => translate('Id'),
+ isSortable: true,
+ isVisible: false
+ },
{
name: 'sortName',
- label: translate('IndexerName'),
+ label: () => translate('IndexerName'),
isSortable: true,
- isVisible: true,
- isModifiable: false
+ isVisible: true
},
{
name: 'protocol',
- label: translate('Protocol'),
+ label: () => translate('Protocol'),
isSortable: true,
isVisible: true
},
{
name: 'privacy',
- label: translate('Privacy'),
+ label: () => translate('Privacy'),
isSortable: true,
isVisible: true
},
{
name: 'priority',
- label: translate('Priority'),
+ label: () => translate('Priority'),
isSortable: true,
isVisible: true
},
{
name: 'appProfileId',
- label: translate('SyncProfile'),
+ label: () => translate('SyncProfile'),
isSortable: true,
isVisible: true
},
{
name: 'added',
- label: translate('Added'),
+ label: () => translate('Added'),
isSortable: true,
isVisible: true
},
{
name: 'vipExpiration',
- label: translate('VipExpiration'),
+ label: () => translate('VipExpiration'),
isSortable: true,
isVisible: false
},
{
name: 'capabilities',
- label: translate('Categories'),
+ label: () => translate('Categories'),
isSortable: false,
isVisible: true
},
+ {
+ name: 'minimumSeeders',
+ label: () => translate('MinimumSeeders'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'seedRatio',
+ label: () => translate('SeedRatio'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'seedTime',
+ label: () => translate('SeedTime'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'packSeedTime',
+ label: () => translate('PackSeedTime'),
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'preferMagnetUrl',
+ label: () => translate('PreferMagnetUrl'),
+ isSortable: true,
+ isVisible: false
+ },
{
name: 'tags',
- label: translate('Tags'),
+ label: () => translate('Tags'),
isSortable: false,
isVisible: false
},
{
name: 'actions',
- columnLabel: translate('Actions'),
+ columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
@@ -116,53 +148,59 @@ export const defaultState = {
filterBuilderProps: [
{
name: 'name',
- label: translate('IndexerName'),
+ label: () => translate('IndexerName'),
type: filterBuilderTypes.STRING
},
{
name: 'enable',
- label: translate('Enabled'),
+ label: () => translate('Enabled'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'added',
- label: translate('Added'),
+ label: () => translate('Added'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE
},
{
name: 'vipExpiration',
- label: translate('VipExpiration'),
+ label: () => translate('VipExpiration'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE
},
{
name: 'priority',
- label: translate('Priority'),
+ label: () => translate('Priority'),
type: filterBuilderTypes.NUMBER
},
{
name: 'protocol',
- label: translate('Protocol'),
+ label: () => translate('Protocol'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.PROTOCOL
},
{
name: 'privacy',
- label: translate('Privacy'),
+ label: () => translate('Privacy'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.PRIVACY
},
{
name: 'appProfileId',
- label: translate('SyncProfile'),
+ label: () => translate('SyncProfile'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.APP_PROFILE
},
+ {
+ name: 'categories',
+ label: () => translate('Categories'),
+ type: filterBuilderTypes.ARRAY,
+ valueType: filterBuilderValueTypes.CATEGORY
+ },
{
name: 'tags',
- label: translate('Tags'),
+ label: () => translate('Tags'),
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG
}
@@ -186,8 +224,6 @@ export const SET_INDEXER_SORT = 'indexerIndex/setIndexerSort';
export const SET_INDEXER_FILTER = 'indexerIndex/setIndexerFilter';
export const SET_INDEXER_VIEW = 'indexerIndex/setIndexerView';
export const SET_INDEXER_TABLE_OPTION = 'indexerIndex/setIndexerTableOption';
-export const SAVE_INDEXER_EDITOR = 'indexerIndex/saveIndexerEditor';
-export const BULK_DELETE_INDEXERS = 'indexerIndex/bulkDeleteIndexers';
//
// Action Creators
@@ -196,89 +232,6 @@ export const setIndexerSort = createAction(SET_INDEXER_SORT);
export const setIndexerFilter = createAction(SET_INDEXER_FILTER);
export const setIndexerView = createAction(SET_INDEXER_VIEW);
export const setIndexerTableOption = createAction(SET_INDEXER_TABLE_OPTION);
-export const saveIndexerEditor = createThunk(SAVE_INDEXER_EDITOR);
-export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
-
-//
-// Action Handlers
-
-export const actionHandlers = handleThunks({
- [SAVE_INDEXER_EDITOR]: function(getState, payload, dispatch) {
- dispatch(set({
- section,
- isSaving: true
- }));
-
- const promise = createAjaxRequest({
- url: '/indexer/editor',
- method: 'PUT',
- data: JSON.stringify(payload),
- dataType: 'json'
- }).request;
-
- promise.done((data) => {
- dispatch(batchActions([
- ...data.map((indexer) => {
- return updateItem({
- id: indexer.id,
- section: 'indexers',
- ...indexer
- });
- }),
-
- set({
- section,
- isSaving: false,
- saveError: null
- })
- ]));
- });
-
- promise.fail((xhr) => {
- dispatch(set({
- section,
- isSaving: false,
- saveError: xhr
- }));
- });
- },
-
- [BULK_DELETE_INDEXERS]: function(getState, payload, dispatch) {
- dispatch(set({
- section,
- isDeleting: true
- }));
-
- const promise = createAjaxRequest({
- url: '/indexer/editor',
- method: 'DELETE',
- data: JSON.stringify(payload),
- dataType: 'json'
- }).request;
-
- promise.done(() => {
- dispatch(batchActions([
- ...payload.indexerIds.map((id) => {
- return removeItem({ section: 'indexers', id });
- }),
-
- set({
- section,
- isDeleting: false,
- deleteError: null
- })
- ]));
- });
-
- promise.fail((xhr) => {
- dispatch(set({
- section,
- isDeleting: false,
- deleteError: xhr
- }));
- });
- }
-});
//
// Reducers
diff --git a/frontend/src/Store/Actions/indexerStatsActions.js b/frontend/src/Store/Actions/indexerStatsActions.js
index e937cee93..06c9586b5 100644
--- a/frontend/src/Store/Actions/indexerStatsActions.js
+++ b/frontend/src/Store/Actions/indexerStatsActions.js
@@ -3,6 +3,7 @@ import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
+import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { set, update } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
@@ -33,7 +34,7 @@ export const defaultState = {
filters: [
{
key: 'all',
- label: translate('All'),
+ label: () => translate('All'),
filters: []
},
{
@@ -55,19 +56,27 @@ export const defaultState = {
filterBuilderProps: [
{
- name: 'startDate',
- label: 'Start Date',
- type: filterBuilderTypes.EXACT,
- valueType: filterBuilderValueTypes.DATE
+ name: 'indexers',
+ label: () => translate('Indexers'),
+ type: filterBuilderTypes.CONTAINS,
+ valueType: filterBuilderValueTypes.INDEXER
},
{
- name: 'endDate',
- label: 'End Date',
+ name: 'protocols',
+ label: () => translate('Protocols'),
type: filterBuilderTypes.EXACT,
- valueType: filterBuilderValueTypes.DATE
+ valueType: filterBuilderValueTypes.PROTOCOL
+ },
+ {
+ name: 'tags',
+ label: () => translate('Tags'),
+ type: filterBuilderTypes.CONTAINS,
+ valueType: filterBuilderValueTypes.TAG
}
],
+
selectedFilterKey: 'all'
+
};
export const persistState = [
@@ -81,6 +90,10 @@ export const persistState = [
export const FETCH_INDEXER_STATS = 'indexerStats/fetchIndexerStats';
export const SET_INDEXER_STATS_FILTER = 'indexerStats/setIndexerStatsFilter';
+function getCustomFilters(state, type) {
+ return state.customFilters.items.filter((customFilter) => customFilter.type === type);
+}
+
//
// Action Creators
@@ -94,23 +107,39 @@ export const actionHandlers = handleThunks({
[FETCH_INDEXER_STATS]: function(getState, payload, dispatch) {
const state = getState();
const indexerStats = state.indexerStats;
+ const customFilters = getCustomFilters(state, section);
+ const selectedFilters = findSelectedFilters(indexerStats.selectedFilterKey, indexerStats.filters, customFilters);
const requestParams = {
endDate: moment().toISOString()
};
+ selectedFilters.forEach((selectedFilter) => {
+ if (selectedFilter.key === 'indexers') {
+ requestParams.indexers = selectedFilter.value.join(',');
+ }
+
+ if (selectedFilter.key === 'protocols') {
+ requestParams.protocols = selectedFilter.value.join(',');
+ }
+
+ if (selectedFilter.key === 'tags') {
+ requestParams.tags = selectedFilter.value.join(',');
+ }
+ });
+
if (indexerStats.selectedFilterKey !== 'all') {
- let dayCount = 7;
+ if (indexerStats.selectedFilterKey === 'lastSeven') {
+ requestParams.startDate = moment().add(-7, 'days').endOf('day').toISOString();
+ }
if (indexerStats.selectedFilterKey === 'lastThirty') {
- dayCount = 30;
+ requestParams.startDate = moment().add(-30, 'days').endOf('day').toISOString();
}
if (indexerStats.selectedFilterKey === 'lastNinety') {
- dayCount = 90;
+ requestParams.startDate = moment().add(-90, 'days').endOf('day').toISOString();
}
-
- requestParams.startDate = moment().add(-dayCount, 'days').endOf('day').toISOString();
}
const basesAttrs = {
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
index b6b05d05e..b5b4966ac 100644
--- a/frontend/src/Store/Actions/oAuthActions.js
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -60,7 +60,7 @@ function showOAuthWindow(url, payload) {
responseJSON: [
{
propertyName: payload.name,
- errorMessage: translate('OAuthPopupMessage')
+ errorMessage: () => translate('OAuthPopupMessage')
}
]
};
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
index 336c9add8..fd2fe441b 100644
--- a/frontend/src/Store/Actions/releaseActions.js
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -1,7 +1,9 @@
import $ from 'jquery';
+import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
-import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
@@ -31,16 +33,18 @@ export const defaultState = {
error: null,
grabError: null,
items: [],
- sortKey: 'title',
+ sortKey: 'age',
sortDirection: sortDirections.ASCENDING,
- secondarySortKey: 'title',
+ secondarySortKey: 'sortTitle',
secondarySortDirection: sortDirections.ASCENDING,
defaults: {
searchType: 'search',
searchQuery: '',
searchIndexerIds: [],
- searchCategories: []
+ searchCategories: [],
+ searchLimit: 100,
+ searchOffset: 0
},
columns: [
@@ -54,67 +58,71 @@ export const defaultState = {
},
{
name: 'protocol',
- label: translate('Protocol'),
+ label: () => translate('Protocol'),
isSortable: true,
isVisible: true
},
{
name: 'age',
- label: translate('Age'),
+ label: () => translate('Age'),
isSortable: true,
isVisible: true
},
{
name: 'sortTitle',
- label: translate('Title'),
+ label: () => translate('Title'),
isSortable: true,
isVisible: true
},
{
name: 'indexer',
- label: translate('Indexer'),
+ label: () => translate('Indexer'),
isSortable: true,
isVisible: true
},
{
name: 'size',
- label: translate('Size'),
+ label: () => translate('Size'),
isSortable: true,
isVisible: true
},
{
name: 'files',
- label: translate('Files'),
+ label: () => translate('Files'),
isSortable: true,
isVisible: false
},
{
name: 'grabs',
- label: translate('Grabs'),
+ label: () => translate('Grabs'),
isSortable: true,
isVisible: true
},
{
name: 'peers',
- label: translate('Peers'),
+ label: () => translate('Peers'),
isSortable: true,
isVisible: true
},
{
name: 'category',
- label: translate('Category'),
+ label: () => translate('Category'),
isSortable: true,
isVisible: true
},
{
name: 'indexerFlags',
- columnLabel: 'Indexer Flags',
+ columnLabel: () => translate('IndexerFlags'),
+ label: React.createElement(Icon, {
+ name: icons.FLAG,
+ title: () => translate('IndexerFlags')
+ }),
isSortable: true,
isVisible: true
},
{
name: 'actions',
- columnLabel: translate('Actions'),
+ columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
@@ -156,57 +164,70 @@ export const defaultState = {
filters: [
{
key: 'all',
- label: translate('All'),
+ label: () => translate('All'),
filters: []
}
],
+ filterPredicates: {
+ peers: function(item, filterValue, type) {
+ const predicate = filterTypePredicates[type];
+
+ const seeders = item.seeders || 0;
+ const leechers = item.leechers || 0;
+ const peers = seeders + leechers;
+
+ return predicate(peers, filterValue);
+ }
+ },
+
filterBuilderProps: [
{
name: 'title',
- label: translate('Title'),
+ label: () => translate('Title'),
type: filterBuilderTypes.STRING
},
{
name: 'age',
- label: translate('Age'),
+ label: () => translate('Age'),
type: filterBuilderTypes.NUMBER
},
{
name: 'protocol',
- label: translate('Protocol'),
+ label: () => translate('Protocol'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.PROTOCOL
},
{
name: 'indexerId',
- label: translate('Indexer'),
+ label: () => translate('Indexer'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.INDEXER
},
{
name: 'size',
- label: translate('Size'),
- type: filterBuilderTypes.NUMBER
+ label: () => translate('Size'),
+ type: filterBuilderTypes.NUMBER,
+ valueType: filterBuilderValueTypes.BYTES
},
{
name: 'files',
- label: translate('Files'),
+ label: () => translate('Files'),
type: filterBuilderTypes.NUMBER
},
{
name: 'grabs',
- label: translate('Grabs'),
+ label: () => translate('Grabs'),
type: filterBuilderTypes.NUMBER
},
{
name: 'seeders',
- label: translate('Seeders'),
+ label: () => translate('Seeders'),
type: filterBuilderTypes.NUMBER
},
{
name: 'peers',
- label: translate('Peers'),
+ label: () => translate('Peers'),
type: filterBuilderTypes.NUMBER
}
],
@@ -348,8 +369,9 @@ export const actionHandlers = handleThunks({
promise.done((data) => {
dispatch(batchActions([
- ...data.map((release) => {
+ ...data.map(({ guid }) => {
return updateRelease({
+ guid,
isGrabbing: false,
isGrabbed: true,
grabError: null
@@ -379,7 +401,16 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({
[CLEAR_RELEASES]: (state) => {
- return Object.assign({}, state, defaultState);
+ const {
+ sortKey,
+ sortDirection,
+ customFilters,
+ selectedFilterKey,
+ columns,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
},
[UPDATE_RELEASE]: (state, { payload }) => {
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
index 4910e462d..75d2595cf 100644
--- a/frontend/src/Store/Actions/systemActions.js
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -82,35 +82,34 @@ export const defaultState = {
columns: [
{
name: 'level',
- columnLabel: translate('Level'),
+ columnLabel: () => translate('Level'),
isSortable: false,
isVisible: true,
isModifiable: false
},
{
name: 'time',
- label: translate('Time'),
+ label: () => translate('Time'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'logger',
- label: translate('Component'),
+ label: () => translate('Component'),
isSortable: false,
isVisible: true,
isModifiable: false
},
{
name: 'message',
- label: translate('Message'),
+ label: () => translate('Message'),
isVisible: true,
isModifiable: false
},
{
name: 'actions',
- columnLabel: translate('Actions'),
- isSortable: true,
+ columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
@@ -121,12 +120,12 @@ export const defaultState = {
filters: [
{
key: 'all',
- label: translate('All'),
+ label: () => translate('All'),
filters: []
},
{
key: 'info',
- label: translate('Info'),
+ label: () => translate('Info'),
filters: [
{
key: 'level',
@@ -137,7 +136,7 @@ export const defaultState = {
},
{
key: 'warn',
- label: translate('Warn'),
+ label: () => translate('Warn'),
filters: [
{
key: 'level',
@@ -148,7 +147,7 @@ export const defaultState = {
},
{
key: 'error',
- label: translate('Error'),
+ label: () => translate('Error'),
filters: [
{
key: 'level',
diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js
index 73047b5de..1840959ed 100644
--- a/frontend/src/Store/Middleware/createPersistState.js
+++ b/frontend/src/Store/Middleware/createPersistState.js
@@ -36,10 +36,17 @@ function mergeColumns(path, initialState, persistedState, computedState) {
const column = initialColumns.find((i) => i.name === persistedColumn.name);
if (column) {
- columns.push({
- ...column,
- isVisible: persistedColumn.isVisible
- });
+ const newColumn = {};
+
+ // We can't use a spread operator or Object.assign to clone the column
+ // or any accessors are lost and can break translations.
+ for (const prop of Object.keys(column)) {
+ Object.defineProperty(newColumn, prop, Object.getOwnPropertyDescriptor(column, prop));
+ }
+
+ newColumn.isVisible = persistedColumn.isVisible;
+
+ columns.push(newColumn);
}
});
diff --git a/frontend/src/Store/Selectors/createAllIndexersSelector.js b/frontend/src/Store/Selectors/createAllIndexersSelector.ts
similarity index 71%
rename from frontend/src/Store/Selectors/createAllIndexersSelector.js
rename to frontend/src/Store/Selectors/createAllIndexersSelector.ts
index 178c54eed..76641025f 100644
--- a/frontend/src/Store/Selectors/createAllIndexersSelector.js
+++ b/frontend/src/Store/Selectors/createAllIndexersSelector.ts
@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createAllIndexersSelector() {
return createSelector(
- (state) => state.indexers,
+ (state: AppState) => state.indexers,
(indexers) => {
return indexers.items;
}
diff --git a/frontend/src/Store/Selectors/createAppProfileSelector.js b/frontend/src/Store/Selectors/createAppProfileSelector.js
deleted file mode 100644
index 42452ccfd..000000000
--- a/frontend/src/Store/Selectors/createAppProfileSelector.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { createSelector } from 'reselect';
-
-function createAppProfileSelector() {
- return createSelector(
- (state, { appProfileId }) => appProfileId,
- (state) => state.settings.appProfiles.items,
- (appProfileId, appProfiles) => {
- return appProfiles.find((profile) => {
- return profile.id === appProfileId;
- });
- }
- );
-}
-
-export default createAppProfileSelector;
diff --git a/frontend/src/Store/Selectors/createAppProfileSelector.ts b/frontend/src/Store/Selectors/createAppProfileSelector.ts
new file mode 100644
index 000000000..b26ab71a4
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAppProfileSelector.ts
@@ -0,0 +1,14 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createAppProfileSelector() {
+ return createSelector(
+ (_: AppState, { appProfileId }: { appProfileId: number }) => appProfileId,
+ (state: AppState) => state.settings.appProfiles.items,
+ (appProfileId, appProfiles) => {
+ return appProfiles.find((profile) => profile.id === appProfileId);
+ }
+ );
+}
+
+export default createAppProfileSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
index ae1031dca..1bac14f08 100644
--- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
+++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
@@ -108,7 +108,7 @@ function sort(items, state) {
return _.orderBy(items, clauses, orders);
}
-function createCustomFiltersSelector(type, alternateType) {
+export function createCustomFiltersSelector(type, alternateType) {
return createSelector(
(state) => state.customFilters.items,
(customFilters) => {
diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts
similarity index 50%
rename from frontend/src/Store/Selectors/createCommandExecutingSelector.js
rename to frontend/src/Store/Selectors/createCommandExecutingSelector.ts
index 6037d5820..6a80e172b 100644
--- a/frontend/src/Store/Selectors/createCommandExecutingSelector.js
+++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts
@@ -2,13 +2,10 @@ import { createSelector } from 'reselect';
import { isCommandExecuting } from 'Utilities/Command';
import createCommandSelector from './createCommandSelector';
-function createCommandExecutingSelector(name, contraints = {}) {
- return createSelector(
- createCommandSelector(name, contraints),
- (command) => {
- return isCommandExecuting(command);
- }
- );
+function createCommandExecutingSelector(name: string, contraints = {}) {
+ return createSelector(createCommandSelector(name, contraints), (command) => {
+ return isCommandExecuting(command);
+ });
}
export default createCommandExecutingSelector;
diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js
deleted file mode 100644
index 709dfebaf..000000000
--- a/frontend/src/Store/Selectors/createCommandSelector.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { createSelector } from 'reselect';
-import { findCommand } from 'Utilities/Command';
-import createCommandsSelector from './createCommandsSelector';
-
-function createCommandSelector(name, contraints = {}) {
- return createSelector(
- createCommandsSelector(),
- (commands) => {
- return findCommand(commands, { name, ...contraints });
- }
- );
-}
-
-export default createCommandSelector;
diff --git a/frontend/src/Store/Selectors/createCommandSelector.ts b/frontend/src/Store/Selectors/createCommandSelector.ts
new file mode 100644
index 000000000..cced7b186
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandSelector.ts
@@ -0,0 +1,11 @@
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandSelector(name: string, contraints = {}) {
+ return createSelector(createCommandsSelector(), (commands) => {
+ return findCommand(commands, { name, ...contraints });
+ });
+}
+
+export default createCommandSelector;
diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.ts
similarity index 71%
rename from frontend/src/Store/Selectors/createCommandsSelector.js
rename to frontend/src/Store/Selectors/createCommandsSelector.ts
index 7b9edffd9..2dd5d24a2 100644
--- a/frontend/src/Store/Selectors/createCommandsSelector.js
+++ b/frontend/src/Store/Selectors/createCommandsSelector.ts
@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createCommandsSelector() {
return createSelector(
- (state) => state.commands,
+ (state: AppState) => state.commands,
(commands) => {
return commands.items;
}
diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.js b/frontend/src/Store/Selectors/createDeepEqualSelector.js
deleted file mode 100644
index 85562f28b..000000000
--- a/frontend/src/Store/Selectors/createDeepEqualSelector.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import _ from 'lodash';
-import { createSelectorCreator, defaultMemoize } from 'reselect';
-
-const createDeepEqualSelector = createSelectorCreator(
- defaultMemoize,
- _.isEqual
-);
-
-export default createDeepEqualSelector;
diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.ts b/frontend/src/Store/Selectors/createDeepEqualSelector.ts
new file mode 100644
index 000000000..9d4a63d2e
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDeepEqualSelector.ts
@@ -0,0 +1,6 @@
+import { isEqual } from 'lodash';
+import { createSelectorCreator, defaultMemoize } from 'reselect';
+
+const createDeepEqualSelector = createSelectorCreator(defaultMemoize, isEqual);
+
+export default createDeepEqualSelector;
diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.ts
similarity index 69%
rename from frontend/src/Store/Selectors/createDimensionsSelector.js
rename to frontend/src/Store/Selectors/createDimensionsSelector.ts
index ce26b2e2c..b9602cb02 100644
--- a/frontend/src/Store/Selectors/createDimensionsSelector.js
+++ b/frontend/src/Store/Selectors/createDimensionsSelector.ts
@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createDimensionsSelector() {
return createSelector(
- (state) => state.app.dimensions,
+ (state: AppState) => state.app.dimensions,
(dimensions) => {
return dimensions;
}
diff --git a/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts
new file mode 100644
index 000000000..3a581587b
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts
@@ -0,0 +1,26 @@
+import { createSelector } from 'reselect';
+import { DownloadClientAppState } from 'App/State/SettingsAppState';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import DownloadClient from 'typings/DownloadClient';
+import sortByProp from 'Utilities/Array/sortByProp';
+
+export default function createEnabledDownloadClientsSelector(
+ protocol: DownloadProtocol
+) {
+ return createSelector(
+ createSortedSectionSelector(
+ 'settings.downloadClients',
+ sortByProp('name')
+ ),
+ (downloadClients: DownloadClientAppState) => {
+ const { isFetching, isPopulated, error, items } = downloadClients;
+
+ const clients = items.filter(
+ (item) => item.protocol === protocol && item.enable
+ );
+
+ return { isFetching, isPopulated, error, items: clients };
+ }
+ );
+}
diff --git a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts
similarity index 78%
rename from frontend/src/Store/Selectors/createExecutingCommandsSelector.js
rename to frontend/src/Store/Selectors/createExecutingCommandsSelector.ts
index 266865a8a..dd16571fc 100644
--- a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js
+++ b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts
@@ -1,9 +1,10 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
import { isCommandExecuting } from 'Utilities/Command';
function createExecutingCommandsSelector() {
return createSelector(
- (state) => state.commands.items,
+ (state: AppState) => state.commands.items,
(commands) => {
return commands.filter((command) => isCommandExecuting(command));
}
diff --git a/frontend/src/Store/Selectors/createExistingIndexerSelector.js b/frontend/src/Store/Selectors/createExistingIndexerSelector.ts
similarity index 59%
rename from frontend/src/Store/Selectors/createExistingIndexerSelector.js
rename to frontend/src/Store/Selectors/createExistingIndexerSelector.ts
index af16973b7..df98ab8d5 100644
--- a/frontend/src/Store/Selectors/createExistingIndexerSelector.js
+++ b/frontend/src/Store/Selectors/createExistingIndexerSelector.ts
@@ -1,13 +1,15 @@
-import _ from 'lodash';
+import { some } from 'lodash';
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
import createAllIndexersSelector from './createAllIndexersSelector';
function createExistingIndexerSelector() {
return createSelector(
- (state, { definitionName }) => definitionName,
+ (_: AppState, { definitionName }: { definitionName: string }) =>
+ definitionName,
createAllIndexersSelector(),
(definitionName, indexers) => {
- return _.some(indexers, { definitionName });
+ return some(indexers, { definitionName });
}
);
}
diff --git a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js
deleted file mode 100644
index 683f0419b..000000000
--- a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { createSelector } from 'reselect';
-import createIndexerSelector from './createIndexerSelector';
-
-function createIndexerAppProfileSelector(indexerId) {
- return createSelector(
- (state) => state.settings.appProfiles.items,
- createIndexerSelector(indexerId),
- (appProfiles, indexer = {}) => {
- return appProfiles.find((profile) => {
- return profile.id === indexer.appProfileId;
- });
- }
- );
-}
-
-export default createIndexerAppProfileSelector;
diff --git a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.ts b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.ts
new file mode 100644
index 000000000..ea95a9443
--- /dev/null
+++ b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.ts
@@ -0,0 +1,16 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import Indexer from 'Indexer/Indexer';
+import { createIndexerSelectorForHook } from './createIndexerSelector';
+
+function createIndexerAppProfileSelector(indexerId: number) {
+ return createSelector(
+ (state: AppState) => state.settings.appProfiles.items,
+ createIndexerSelectorForHook(indexerId),
+ (appProfiles, indexer = {} as Indexer) => {
+ return appProfiles.find((profile) => profile.id === indexer.appProfileId);
+ }
+ );
+}
+
+export default createIndexerAppProfileSelector;
diff --git a/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js
index 931bddf23..c0edaa6dd 100644
--- a/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js
+++ b/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js
@@ -9,12 +9,12 @@ function createUnoptimizedSelector(uiSection) {
const items = indexers.items.map((s) => {
const {
id,
- name
+ sortName
} = s;
return {
id,
- sortTitle: name
+ sortName
};
});
@@ -38,7 +38,7 @@ const createMovieEqualSelector = createSelectorCreator(
function createIndexerClientSideCollectionItemsSelector(uiSection) {
return createMovieEqualSelector(
createUnoptimizedSelector(uiSection),
- (movies) => movies
+ (indexers) => indexers
);
}
diff --git a/frontend/src/Store/Selectors/createIndexerSelector.js b/frontend/src/Store/Selectors/createIndexerSelector.js
deleted file mode 100644
index 220f9b15e..000000000
--- a/frontend/src/Store/Selectors/createIndexerSelector.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createSelector } from 'reselect';
-
-function createIndexerSelector(id) {
- if (id == null) {
- return createSelector(
- (state, { indexerId }) => indexerId,
- (state) => state.indexers.itemMap,
- (state) => state.indexers.items,
- (indexerId, itemMap, allIndexers) => {
- return allIndexers[itemMap[indexerId]];
- }
- );
- }
-
- return createSelector(
- (state) => state.indexers.itemMap,
- (state) => state.indexers.items,
- (itemMap, allIndexers) => {
- return allIndexers[itemMap[id]];
- }
- );
-}
-
-export default createIndexerSelector;
diff --git a/frontend/src/Store/Selectors/createIndexerSelector.ts b/frontend/src/Store/Selectors/createIndexerSelector.ts
new file mode 100644
index 000000000..7227d18a6
--- /dev/null
+++ b/frontend/src/Store/Selectors/createIndexerSelector.ts
@@ -0,0 +1,25 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+export function createIndexerSelectorForHook(indexerId: number) {
+ return createSelector(
+ (state: AppState) => state.indexers.itemMap,
+ (state: AppState) => state.indexers.items,
+ (itemMap, allIndexers) => {
+ return indexerId ? allIndexers[itemMap[indexerId]] : undefined;
+ }
+ );
+}
+
+function createIndexerSelector() {
+ return createSelector(
+ (_: AppState, { indexerId }: { indexerId: number }) => indexerId,
+ (state: AppState) => state.indexers.itemMap,
+ (state: AppState) => state.indexers.items,
+ (indexerId, itemMap, allIndexers) => {
+ return allIndexers[itemMap[indexerId]];
+ }
+ );
+}
+
+export default createIndexerSelector;
diff --git a/frontend/src/Store/Selectors/createIndexerStatusSelector.js b/frontend/src/Store/Selectors/createIndexerStatusSelector.js
deleted file mode 100644
index 1912ea1a0..000000000
--- a/frontend/src/Store/Selectors/createIndexerStatusSelector.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import _ from 'lodash';
-import { createSelector } from 'reselect';
-
-function createIndexerStatusSelector(indexerId) {
- return createSelector(
- (state) => state.indexerStatus.items,
- (indexerStatus) => {
- return _.find(indexerStatus, { indexerId });
- }
- );
-}
-
-export default createIndexerStatusSelector;
diff --git a/frontend/src/Store/Selectors/createIndexerStatusSelector.ts b/frontend/src/Store/Selectors/createIndexerStatusSelector.ts
new file mode 100644
index 000000000..035dfc3c4
--- /dev/null
+++ b/frontend/src/Store/Selectors/createIndexerStatusSelector.ts
@@ -0,0 +1,15 @@
+import { find } from 'lodash';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { IndexerStatus } from 'Indexer/Indexer';
+
+function createIndexerStatusSelector(indexerId: number) {
+ return createSelector(
+ (state: AppState) => state.indexerStatus.items,
+ (indexerStatus) => {
+ return find(indexerStatus, { indexerId }) as IndexerStatus | undefined;
+ }
+ );
+}
+
+export default createIndexerStatusSelector;
diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js
deleted file mode 100644
index 807bf4673..000000000
--- a/frontend/src/Store/Selectors/createProfileInUseSelector.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import _ from 'lodash';
-import { createSelector } from 'reselect';
-import createAllIndexersSelector from './createAllIndexersSelector';
-
-function createProfileInUseSelector(profileProp) {
- return createSelector(
- (state, { id }) => id,
- (state) => state.settings.appProfiles.items,
- createAllIndexersSelector(),
- (id, profiles, indexers) => {
- if (!id) {
- return false;
- }
-
- if (_.some(indexers, { [profileProp]: id }) || profiles.length <= 1) {
- return true;
- }
-
- return false;
- }
- );
-}
-
-export default createProfileInUseSelector;
diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.ts b/frontend/src/Store/Selectors/createProfileInUseSelector.ts
new file mode 100644
index 000000000..8137db693
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProfileInUseSelector.ts
@@ -0,0 +1,21 @@
+import { some } from 'lodash';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import createAllIndexersSelector from './createAllIndexersSelector';
+
+function createProfileInUseSelector(profileProp: string) {
+ return createSelector(
+ (_: AppState, { id }: { id: number }) => id,
+ (state: AppState) => state.settings.appProfiles.items,
+ createAllIndexersSelector(),
+ (id, profiles, indexers) => {
+ if (!id) {
+ return false;
+ }
+
+ return some(indexers, { [profileProp]: id }) || profiles.length <= 1;
+ }
+ );
+}
+
+export default createProfileInUseSelector;
diff --git a/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js
index c76ba4236..4bc195aa5 100644
--- a/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js
+++ b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js
@@ -9,13 +9,13 @@ function createUnoptimizedSelector(uiSection) {
const items = releases.items.map((s) => {
const {
guid,
- title,
+ sortTitle,
indexerId
} = s;
return {
guid,
- sortTitle: title,
+ sortTitle,
indexerId
};
});
@@ -40,7 +40,7 @@ const createMovieEqualSelector = createSelectorCreator(
function createReleaseClientSideCollectionItemsSelector(uiSection) {
return createMovieEqualSelector(
createUnoptimizedSelector(uiSection),
- (movies) => movies
+ (releases) => releases
);
}
diff --git a/frontend/src/Store/Selectors/createSortedSectionSelector.js b/frontend/src/Store/Selectors/createSortedSectionSelector.ts
similarity index 68%
rename from frontend/src/Store/Selectors/createSortedSectionSelector.js
rename to frontend/src/Store/Selectors/createSortedSectionSelector.ts
index 331d890c9..abee01f75 100644
--- a/frontend/src/Store/Selectors/createSortedSectionSelector.js
+++ b/frontend/src/Store/Selectors/createSortedSectionSelector.ts
@@ -1,14 +1,18 @@
import { createSelector } from 'reselect';
import getSectionState from 'Utilities/State/getSectionState';
-function createSortedSectionSelector(section, comparer) {
+function createSortedSectionSelector(
+ section: string,
+ comparer: (a: T, b: T) => number
+) {
return createSelector(
(state) => state,
(state) => {
const sectionState = getSectionState(state, section, true);
+
return {
...sectionState,
- items: [...sectionState.items].sort(comparer)
+ items: [...sectionState.items].sort(comparer),
};
}
);
diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.ts
similarity index 70%
rename from frontend/src/Store/Selectors/createSystemStatusSelector.js
rename to frontend/src/Store/Selectors/createSystemStatusSelector.ts
index df586bbb9..f5e276069 100644
--- a/frontend/src/Store/Selectors/createSystemStatusSelector.js
+++ b/frontend/src/Store/Selectors/createSystemStatusSelector.ts
@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createSystemStatusSelector() {
return createSelector(
- (state) => state.system.status,
+ (state: AppState) => state.system.status,
(status) => {
return status.item;
}
diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.ts
similarity index 62%
rename from frontend/src/Store/Selectors/createTagDetailsSelector.js
rename to frontend/src/Store/Selectors/createTagDetailsSelector.ts
index dd178944c..2a271cafe 100644
--- a/frontend/src/Store/Selectors/createTagDetailsSelector.js
+++ b/frontend/src/Store/Selectors/createTagDetailsSelector.ts
@@ -1,9 +1,10 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createTagDetailsSelector() {
return createSelector(
- (state, { id }) => id,
- (state) => state.tags.details.items,
+ (_: AppState, { id }: { id: number }) => id,
+ (state: AppState) => state.tags.details.items,
(id, tagDetails) => {
return tagDetails.find((t) => t.id === id);
}
diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.ts
similarity index 68%
rename from frontend/src/Store/Selectors/createTagsSelector.js
rename to frontend/src/Store/Selectors/createTagsSelector.ts
index fbfd91cdb..f653ff6e3 100644
--- a/frontend/src/Store/Selectors/createTagsSelector.js
+++ b/frontend/src/Store/Selectors/createTagsSelector.ts
@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createTagsSelector() {
return createSelector(
- (state) => state.tags.items,
+ (state: AppState) => state.tags.items,
(tags) => {
return tags;
}
diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.ts
similarity index 69%
rename from frontend/src/Store/Selectors/createUISettingsSelector.js
rename to frontend/src/Store/Selectors/createUISettingsSelector.ts
index b256d0e98..ff539679b 100644
--- a/frontend/src/Store/Selectors/createUISettingsSelector.js
+++ b/frontend/src/Store/Selectors/createUISettingsSelector.ts
@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
function createUISettingsSelector() {
return createSelector(
- (state) => state.settings.ui,
+ (state: AppState) => state.settings.ui,
(ui) => {
return ui.item;
}
diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js
deleted file mode 100644
index 6aeed381f..000000000
--- a/frontend/src/Store/scrollPositions.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const scrollPositions = {
- indexerIndex: 0
-};
-
-export default scrollPositions;
diff --git a/frontend/src/Store/scrollPositions.ts b/frontend/src/Store/scrollPositions.ts
new file mode 100644
index 000000000..48fc68535
--- /dev/null
+++ b/frontend/src/Store/scrollPositions.ts
@@ -0,0 +1,5 @@
+const scrollPositions: Record = {
+ indexerIndex: 0,
+};
+
+export default scrollPositions;
diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js
deleted file mode 100644
index ebcf10917..000000000
--- a/frontend/src/Store/thunks.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const thunks = {};
-
-function identity(payload) {
- return payload;
-}
-
-export function createThunk(type, identityFunction = identity) {
- return function(payload = {}) {
- return function(dispatch, getState) {
- const thunk = thunks[type];
-
- if (thunk) {
- return thunk(getState, identityFunction(payload), dispatch);
- }
-
- throw Error(`Thunk handler has not been registered for ${type}`);
- };
- };
-}
-
-export function handleThunks(handlers) {
- const types = Object.keys(handlers);
-
- types.forEach((type) => {
- thunks[type] = handlers[type];
- });
-}
-
diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts
new file mode 100644
index 000000000..fd277211e
--- /dev/null
+++ b/frontend/src/Store/thunks.ts
@@ -0,0 +1,39 @@
+import { Dispatch } from 'redux';
+import AppState from 'App/State/AppState';
+
+type GetState = () => AppState;
+type Thunk = (
+ getState: GetState,
+ identityFn: never,
+ dispatch: Dispatch
+) => unknown;
+
+const thunks: Record = {};
+
+function identity(payload: T): TResult {
+ return payload as unknown as TResult;
+}
+
+export function createThunk(type: string, identityFunction = identity) {
+ return function (payload?: T) {
+ return function (dispatch: Dispatch, getState: GetState) {
+ const thunk = thunks[type];
+
+ if (thunk) {
+ const finalPayload = payload ?? {};
+
+ return thunk(getState, identityFunction(finalPayload), dispatch);
+ }
+
+ throw Error(`Thunk handler has not been registered for ${type}`);
+ };
+ };
+}
+
+export function handleThunks(handlers: Record) {
+ const types = Object.keys(handlers);
+
+ types.forEach((type) => {
+ thunks[type] = handlers[type];
+ });
+}
diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css
index 09d26d083..29b2016b9 100644
--- a/frontend/src/Styles/Mixins/scroller.css
+++ b/frontend/src/Styles/Mixins/scroller.css
@@ -1,4 +1,7 @@
@define-mixin scrollbar {
+ scrollbar-color: var(--scrollbarBackgroundColor) transparent;
+ scrollbar-width: thin;
+
&::-webkit-scrollbar {
width: 10px;
height: 10px;
diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js
index b4e0043b8..a7cbb6de0 100644
--- a/frontend/src/Styles/Themes/dark.js
+++ b/frontend/src/Styles/Themes/dark.js
@@ -37,8 +37,8 @@ module.exports = {
// Links
defaultLinkHoverColor: '#fff',
- linkColor: '#rgb(230, 96, 0)',
- linkHoverColor: '#rgb(230, 96, 0, .8)',
+ linkColor: '#5d9cec',
+ linkHoverColor: '#5d9cec',
// Header
pageHeaderBackgroundColor: '#2a2a2a',
@@ -74,9 +74,9 @@ module.exports = {
defaultButtonTextColor: '#eee',
defaultButtonBackgroundColor: '#333',
- defaultBorderColor: '#eaeaea',
+ defaultBorderColor: '#393f45',
defaultHoverBackgroundColor: '#444',
- defaultHoverBorderColor: '#d6d6d6',
+ defaultHoverBorderColor: '#5a6265',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
@@ -162,7 +162,7 @@ module.exports = {
inputHoverBackgroundColor: 'rgba(255, 255, 255, 0.20)',
inputSelectedBackgroundColor: 'rgba(255, 255, 255, 0.05)',
advancedFormLabelColor: '#ff902b',
- disabledCheckInputColor: '#ddd',
+ disabledCheckInputColor: '#999',
disabledInputColor: '#808080',
//
@@ -187,7 +187,8 @@ module.exports = {
//
// Charts
- failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'],
- chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'],
- chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26']
+ chartBackgroundColor: '#262626',
+ failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','),
+ chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','),
+ chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',')
};
diff --git a/frontend/src/Styles/Themes/index.js b/frontend/src/Styles/Themes/index.js
index d93c5dd8c..4dec39164 100644
--- a/frontend/src/Styles/Themes/index.js
+++ b/frontend/src/Styles/Themes/index.js
@@ -2,7 +2,7 @@ import * as dark from './dark';
import * as light from './light';
const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
-const auto = defaultDark ? { ...dark } : { ...light };
+const auto = defaultDark ? dark : light;
export default {
auto,
diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js
index 5ff84460c..f88070a0f 100644
--- a/frontend/src/Styles/Themes/light.js
+++ b/frontend/src/Styles/Themes/light.js
@@ -187,7 +187,8 @@ module.exports = {
//
// Charts
- failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'],
- chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'],
- chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26']
+ chartBackgroundColor: '#fff',
+ failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','),
+ chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','),
+ chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',')
};
diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js
index 3b0077c5a..def48f28e 100644
--- a/frontend/src/Styles/Variables/fonts.js
+++ b/frontend/src/Styles/Variables/fonts.js
@@ -2,7 +2,6 @@ module.exports = {
// Families
defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
- passwordFamily: 'text-security-disc',
// Sizes
extraSmallFontSize: '11px',
diff --git a/frontend/src/Styles/Variables/zIndexes.js b/frontend/src/Styles/Variables/zIndexes.js
index 986ceb548..4d10253a7 100644
--- a/frontend/src/Styles/Variables/zIndexes.js
+++ b/frontend/src/Styles/Variables/zIndexes.js
@@ -1,4 +1,5 @@
module.exports = {
+ pageJumpBarZIndex: 10,
modalZIndex: 1000,
popperZIndex: 2000
};
diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js
index 089f6bcb9..39f7f1123 100644
--- a/frontend/src/System/Backup/BackupRow.js
+++ b/frontend/src/System/Backup/BackupRow.js
@@ -4,7 +4,7 @@ import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
-import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
@@ -110,12 +110,13 @@ class BackupRow extends Component {
{formatBytes(size)}
-
@@ -138,7 +139,9 @@ class BackupRow extends Component {
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteBackup')}
- message={translate('DeleteBackupMessageText', [name])}
+ message={translate('DeleteBackupMessageText', {
+ name
+ })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeletePress}
onCancel={this.onConfirmDeleteModalClose}
diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js
index 7a5e399d0..ede2f97f6 100644
--- a/frontend/src/System/Backup/Backups.js
+++ b/frontend/src/System/Backup/Backups.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
@@ -8,7 +9,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
-import { icons } from 'Helpers/Props';
+import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import BackupRow from './BackupRow';
import RestoreBackupModalConnector from './RestoreBackupModalConnector';
@@ -20,17 +21,17 @@ const columns = [
},
{
name: 'name',
- label: translate('Name'),
+ label: () => translate('Name'),
isVisible: true
},
{
name: 'size',
- label: translate('Size'),
+ label: () => translate('Size'),
isVisible: true
},
{
name: 'time',
- label: translate('Time'),
+ label: () => translate('Time'),
isVisible: true
},
{
@@ -107,16 +108,16 @@ class Backups extends Component {
{
!isFetching && !!error &&
-
- {translate('UnableToLoadBackups')}
-
+
+ {translate('BackupsLoadError')}
+
}
{
noBackups &&
-
+
{translate('NoBackupsAreAvailable')}
-
+
}
{
diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js
index 150c46ad6..9b5daa9f4 100644
--- a/frontend/src/System/Backup/RestoreBackupModalContent.js
+++ b/frontend/src/System/Backup/RestoreBackupModalContent.js
@@ -14,7 +14,7 @@ import styles from './RestoreBackupModalContent.css';
function getErrorMessage(error) {
if (!error || !error.responseJSON || !error.responseJSON.message) {
- return 'Error restoring backup';
+ return translate('ErrorRestoringBackup');
}
return error.responseJSON.message;
@@ -146,7 +146,9 @@ class RestoreBackupModalContent extends Component {
{
- !!id && `Would you like to restore the backup '${name}'?`
+ !!id && translate('WouldYouLikeToRestoreBackup', {
+ name
+ })
}
{
@@ -203,7 +205,7 @@ class RestoreBackupModalContent extends Component {
- Note: Prowlarr will automatically restart and reload the UI during the restore process.
+ {translate('RestartReloadNote')}
@@ -216,7 +218,7 @@ class RestoreBackupModalContent extends Component {
isSpinning={isRestoring}
onPress={this.onRestorePress}
>
- Restore
+ {translate('Restore')}
diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js
index 8d781afee..1c37a03ba 100644
--- a/frontend/src/System/Events/LogsTable.js
+++ b/frontend/src/System/Events/LogsTable.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
+import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
@@ -11,7 +12,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
-import { align, icons } from 'Helpers/Props';
+import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import LogsTableRow from './LogsTableRow';
@@ -82,9 +83,9 @@ function LogsTable(props) {
{
isPopulated && !error && !items.length &&
-
- No events found
-
+
+ {translate('NoEventsFound')}
+
}
{
diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js
index 20e6eafbf..a717cba15 100644
--- a/frontend/src/System/Events/LogsTableConnector.js
+++ b/frontend/src/System/Events/LogsTableConnector.js
@@ -96,7 +96,14 @@ class LogsTableConnector extends Component {
};
onClearLogsPress = () => {
- this.props.executeCommand({ name: commandNames.CLEAR_LOGS });
+ this.props.executeCommand({
+ name: commandNames.CLEAR_LOGS,
+ commandFinished: this.onCommandFinished
+ });
+ };
+
+ onCommandFinished = () => {
+ this.props.gotoLogsFirstPage();
};
//
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js
index afbb1c2fa..13329f17b 100644
--- a/frontend/src/System/Events/LogsTableDetailsModal.js
+++ b/frontend/src/System/Events/LogsTableDetailsModal.js
@@ -28,7 +28,7 @@ function LogsTableDetailsModal(props) {
onModalClose={onModalClose}
>
- Details
+ {translate('Details')}
diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js
index 2de54a189..2c38ea10c 100644
--- a/frontend/src/System/Events/LogsTableRow.js
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
-import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props';
@@ -98,7 +98,7 @@ class LogsTableRow extends Component {
if (name === 'time') {
return (
-
diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js
index 295fb36cd..580071f87 100644
--- a/frontend/src/System/Logs/Files/LogFiles.js
+++ b/frontend/src/System/Logs/Files/LogFiles.js
@@ -1,8 +1,8 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
-import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
@@ -11,7 +11,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
-import { icons } from 'Helpers/Props';
+import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import LogsNavMenu from '../LogsNavMenu';
import LogFilesTableRow from './LogFilesTableRow';
@@ -19,12 +19,12 @@ import LogFilesTableRow from './LogFilesTableRow';
const columns = [
{
name: 'filename',
- label: translate('Filename'),
+ label: () => translate('Filename'),
isVisible: true
},
{
name: 'lastWriteTime',
- label: translate('LastWriteTime'),
+ label: () => translate('LastWriteTime'),
isVisible: true
},
{
@@ -77,13 +77,15 @@ class LogFiles extends Component {
- Log files are located in: {location}
+ {translate('LogFilesLocation', {
+ location
+ })}
{
currentLogView === 'Log Files' &&
- The log level defaults to 'Info' and can be changed in General Settings
+
}
@@ -118,9 +120,9 @@ class LogFiles extends Component {
{
!isFetching && !items.length &&
-
+
{translate('NoLogFiles')}
-
+
}
diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js
index 03d958b33..75921f346 100644
--- a/frontend/src/System/Logs/Files/LogFilesConnector.js
+++ b/frontend/src/System/Logs/Files/LogFilesConnector.js
@@ -7,6 +7,7 @@ import { executeCommand } from 'Store/Actions/commandActions';
import { fetchLogFiles } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import combinePath from 'Utilities/String/combinePath';
+import translate from 'Utilities/String/translate';
import LogFiles from './LogFiles';
function createMapStateToProps() {
@@ -29,7 +30,7 @@ function createMapStateToProps() {
isFetching,
items,
deleteFilesExecuting,
- currentLogView: 'Log Files',
+ currentLogView: translate('LogFiles'),
location: combinePath(isWindows, appData, ['logs'])
};
}
@@ -50,12 +51,6 @@ class LogFilesConnector extends Component {
this.props.fetchLogFiles();
}
- componentDidUpdate(prevProps) {
- if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
- this.props.fetchLogFiles();
- }
- }
-
//
// Listeners
@@ -64,7 +59,14 @@ class LogFilesConnector extends Component {
};
onDeleteFilesPress = () => {
- this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES });
+ this.props.executeCommand({
+ name: commandNames.DELETE_LOG_FILES,
+ commandFinished: this.onCommandFinished
+ });
+ };
+
+ onCommandFinished = () => {
+ this.props.fetchLogFiles();
};
//
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js
index ef08ada4e..1e5ad552d 100644
--- a/frontend/src/System/Logs/Files/LogFilesTableRow.js
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js
@@ -1,9 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
-import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
+import translate from 'Utilities/String/translate';
import styles from './LogFilesTableRow.css';
class LogFilesTableRow extends Component {
@@ -22,7 +23,7 @@ class LogFilesTableRow extends Component {
{filename}
-
@@ -32,7 +33,7 @@ class LogFilesTableRow extends Component {
target="_blank"
noRouter={true}
>
- Download
+ {translate('Download')}
diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js
index cc485f270..923e4f41c 100644
--- a/frontend/src/System/Logs/LogsNavMenu.js
+++ b/frontend/src/System/Logs/LogsNavMenu.js
@@ -4,6 +4,7 @@ import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
+import translate from 'Utilities/String/translate';
class LogsNavMenu extends Component {
@@ -50,13 +51,13 @@ class LogsNavMenu extends Component {
diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js
deleted file mode 100644
index 43b0deb9b..000000000
--- a/frontend/src/System/Status/About/About.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import DescriptionList from 'Components/DescriptionList/DescriptionList';
-import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
-import FieldSet from 'Components/FieldSet';
-import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
-import titleCase from 'Utilities/String/titleCase';
-import translate from 'Utilities/String/translate';
-import StartTime from './StartTime';
-import styles from './About.css';
-
-class About extends Component {
-
- //
- // Render
-
- render() {
- const {
- version,
- packageVersion,
- packageAuthor,
- isNetCore,
- isDocker,
- runtimeVersion,
- migrationVersion,
- databaseVersion,
- databaseType,
- appData,
- startupPath,
- mode,
- startTime,
- timeFormat,
- longDateFormat
- } = this.props;
-
- return (
-
-
-
-
- {
- packageVersion &&
- {packageVersion} {' by '} : packageVersion)}
- />
- }
-
- {
- isNetCore &&
-
- }
-
- {
- isDocker &&
-
- }
-
-
-
-
-
-
-
-
-
-
-
-
- }
- />
-
-
- );
- }
-
-}
-
-About.propTypes = {
- version: PropTypes.string.isRequired,
- packageVersion: PropTypes.string,
- packageAuthor: PropTypes.string,
- isNetCore: PropTypes.bool.isRequired,
- runtimeVersion: PropTypes.string.isRequired,
- isDocker: PropTypes.bool.isRequired,
- databaseType: PropTypes.string.isRequired,
- databaseVersion: PropTypes.string.isRequired,
- migrationVersion: PropTypes.number.isRequired,
- appData: PropTypes.string.isRequired,
- startupPath: PropTypes.string.isRequired,
- mode: PropTypes.string.isRequired,
- startTime: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
- longDateFormat: PropTypes.string.isRequired
-};
-
-export default About;
diff --git a/frontend/src/System/Status/About/About.tsx b/frontend/src/System/Status/About/About.tsx
new file mode 100644
index 000000000..6eb7fe9c7
--- /dev/null
+++ b/frontend/src/System/Status/About/About.tsx
@@ -0,0 +1,103 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import FieldSet from 'Components/FieldSet';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import { fetchStatus } from 'Store/Actions/systemActions';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import StartTime from './StartTime';
+import styles from './About.css';
+
+function About() {
+ const dispatch = useDispatch();
+ const { item } = useSelector((state: AppState) => state.system.status);
+
+ const {
+ version,
+ packageVersion,
+ packageAuthor,
+ isNetCore,
+ isDocker,
+ runtimeVersion,
+ databaseVersion,
+ databaseType,
+ migrationVersion,
+ appData,
+ startupPath,
+ mode,
+ startTime,
+ } = item;
+
+ useEffect(() => {
+ dispatch(fetchStatus());
+ }, [dispatch]);
+
+ return (
+
+
+
+
+ {packageVersion && (
+
+ ) : (
+ packageVersion
+ )
+ }
+ />
+ )}
+
+ {isNetCore ? (
+
+ ) : null}
+
+ {isDocker ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ );
+}
+
+export default About;
diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js
deleted file mode 100644
index 475d9778b..000000000
--- a/frontend/src/System/Status/About/AboutConnector.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchStatus } from 'Store/Actions/systemActions';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import About from './About';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.system.status,
- createUISettingsSelector(),
- (status, uiSettings) => {
- return {
- ...status.item,
- timeFormat: uiSettings.timeFormat,
- longDateFormat: uiSettings.longDateFormat
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- fetchStatus
-};
-
-class AboutConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- this.props.fetchStatus();
- }
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-AboutConnector.propTypes = {
- fetchStatus: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);
diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js
deleted file mode 100644
index 08c820add..000000000
--- a/frontend/src/System/Status/About/StartTime.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import formatDateTime from 'Utilities/Date/formatDateTime';
-import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
-
-function getUptime(startTime) {
- return formatTimeSpan(moment().diff(startTime));
-}
-
-class StartTime extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- const {
- startTime,
- timeFormat,
- longDateFormat
- } = props;
-
- this._timeoutId = null;
-
- this.state = {
- uptime: getUptime(startTime),
- startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
- };
- }
-
- componentDidMount() {
- this._timeoutId = setTimeout(this.onTimeout, 1000);
- }
-
- componentDidUpdate(prevProps) {
- const {
- startTime,
- timeFormat,
- longDateFormat
- } = this.props;
-
- if (
- startTime !== prevProps.startTime ||
- timeFormat !== prevProps.timeFormat ||
- longDateFormat !== prevProps.longDateFormat
- ) {
- this.setState({
- uptime: getUptime(startTime),
- startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
- });
- }
- }
-
- componentWillUnmount() {
- if (this._timeoutId) {
- this._timeoutId = clearTimeout(this._timeoutId);
- }
- }
-
- //
- // Listeners
-
- onTimeout = () => {
- this.setState({ uptime: getUptime(this.props.startTime) });
- this._timeoutId = setTimeout(this.onTimeout, 1000);
- };
-
- //
- // Render
-
- render() {
- const {
- uptime,
- startTime
- } = this.state;
-
- return (
-
- {uptime}
-
- );
- }
-}
-
-StartTime.propTypes = {
- startTime: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
- longDateFormat: PropTypes.string.isRequired
-};
-
-export default StartTime;
diff --git a/frontend/src/System/Status/About/StartTime.tsx b/frontend/src/System/Status/About/StartTime.tsx
new file mode 100644
index 000000000..0fca7806b
--- /dev/null
+++ b/frontend/src/System/Status/About/StartTime.tsx
@@ -0,0 +1,44 @@
+import moment from 'moment';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+
+interface StartTimeProps {
+ startTime: string;
+}
+
+function StartTime(props: StartTimeProps) {
+ const { startTime } = props;
+ const { timeFormat, longDateFormat } = useSelector(
+ createUISettingsSelector()
+ );
+ const [time, setTime] = useState(Date.now());
+
+ const { formattedStartTime, uptime } = useMemo(() => {
+ return {
+ uptime: formatTimeSpan(moment(time).diff(startTime)),
+ formattedStartTime: formatDateTime(
+ startTime,
+ longDateFormat,
+ timeFormat,
+ {
+ includeSeconds: true,
+ }
+ ),
+ };
+ }, [startTime, time, longDateFormat, timeFormat]);
+
+ useEffect(() => {
+ const interval = setInterval(() => setTime(Date.now()), 1000);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, [setTime]);
+
+ return {uptime};
+}
+
+export default StartTime;
diff --git a/frontend/src/System/Status/Donations/Donations.js b/frontend/src/System/Status/Donations/Donations.js
deleted file mode 100644
index a89e0dea8..000000000
--- a/frontend/src/System/Status/Donations/Donations.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import React, { Component } from 'react';
-import FieldSet from 'Components/FieldSet';
-import Link from 'Components/Link/Link';
-import translate from 'Utilities/String/translate';
-import styles from '../styles.css';
-
-class Donations extends Component {
-
- //
- // Render
-
- render() {
- return (
-
-
-
-

-
-
-
-
-

-
-
-
-
-

-
-
-
-
-

-
-
-
-
-

-
-
-
- );
- }
-}
-
-Donations.propTypes = {
-
-};
-
-export default Donations;
diff --git a/frontend/src/System/Status/Donations/Donations.tsx b/frontend/src/System/Status/Donations/Donations.tsx
new file mode 100644
index 000000000..03e50c317
--- /dev/null
+++ b/frontend/src/System/Status/Donations/Donations.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import FieldSet from 'Components/FieldSet';
+import Link from 'Components/Link/Link';
+import translate from 'Utilities/String/translate';
+import styles from '../styles.css';
+
+function Donations() {
+ return (
+
+
+
+

+
+
+
+
+
+

+
+
+
+
+
+

+
+
+
+
+
+

+
+
+
+
+
+

+
+
+
+ );
+}
+
+export default Donations;
diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css
index dc1a9676e..ceeefa0de 100644
--- a/frontend/src/System/Status/Health/Health.css
+++ b/frontend/src/System/Status/Health/Health.css
@@ -20,5 +20,7 @@
}
.actions {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
min-width: 90px;
}
diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js
deleted file mode 100644
index d4981a4f8..000000000
--- a/frontend/src/System/Status/Health/Health.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FieldSet from 'Components/FieldSet';
-import Icon from 'Components/Icon';
-import IconButton from 'Components/Link/IconButton';
-import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import TableRowCell from 'Components/Table/Cells/TableRowCell';
-import Table from 'Components/Table/Table';
-import TableBody from 'Components/Table/TableBody';
-import TableRow from 'Components/Table/TableRow';
-import { icons, kinds } from 'Helpers/Props';
-import titleCase from 'Utilities/String/titleCase';
-import translate from 'Utilities/String/translate';
-import styles from './Health.css';
-
-function getInternalLink(source) {
- switch (source) {
- case 'IndexerRssCheck':
- case 'IndexerSearchCheck':
- case 'IndexerStatusCheck':
- case 'IndexerLongTermStatusCheck':
- return (
-
- );
- case 'UpdateCheck':
- return (
-
- );
- default:
- return;
- }
-}
-
-function getTestLink(source, props) {
- switch (source) {
- case 'IndexerStatusCheck':
- case 'IndexerLongTermStatusCheck':
- return (
-
- );
-
- default:
- break;
- }
-}
-
-const columns = [
- {
- className: styles.status,
- name: 'type',
- isVisible: true
- },
- {
- name: 'message',
- label: translate('Message'),
- isVisible: true
- },
- {
- name: 'actions',
- label: translate('Actions'),
- isVisible: true
- }
-];
-
-class Health extends Component {
-
- //
- // Render
-
- render() {
- const {
- isFetching,
- isPopulated,
- items
- } = this.props;
-
- const healthIssues = !!items.length;
-
- return (
-
- {translate('Health')}
-
- {
- isFetching && isPopulated &&
-
- }
-
- }
- >
- {
- isFetching && !isPopulated &&
-
- }
-
- {
- !healthIssues &&
-
- {translate('HealthNoIssues')}
-
- }
-
- {
- healthIssues &&
-
-
- {
- items.map((item) => {
- const internalLink = getInternalLink(item.source);
- const testLink = getTestLink(item.source, this.props);
-
- let kind = kinds.WARNING;
- switch (item.type.toLowerCase()) {
- case 'error':
- kind = kinds.DANGER;
- break;
- default:
- case 'warning':
- kind = kinds.WARNING;
- break;
- case 'notice':
- kind = kinds.INFO;
- break;
- }
-
- return (
-
-
-
-
-
- {item.message}
-
-
-
-
- {
- internalLink
- }
-
- {
- !!testLink &&
- testLink
- }
-
-
- );
- })
- }
-
-
- }
-
- );
- }
-
-}
-
-Health.propTypes = {
- isFetching: PropTypes.bool.isRequired,
- isPopulated: PropTypes.bool.isRequired,
- items: PropTypes.array.isRequired,
- isTestingAllIndexers: PropTypes.bool.isRequired,
- dispatchTestAllIndexers: PropTypes.func.isRequired
-};
-
-export default Health;
diff --git a/frontend/src/System/Status/Health/Health.tsx b/frontend/src/System/Status/Health/Health.tsx
new file mode 100644
index 000000000..e0636961b
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.tsx
@@ -0,0 +1,191 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import Alert from 'Components/Alert';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Column from 'Components/Table/Column';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import { icons, kinds } from 'Helpers/Props';
+import { testAllIndexers } from 'Store/Actions/indexerActions';
+import {
+ testAllApplications,
+ testAllDownloadClients,
+} from 'Store/Actions/settingsActions';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import titleCase from 'Utilities/String/titleCase';
+import translate from 'Utilities/String/translate';
+import createHealthSelector from './createHealthSelector';
+import HealthItemLink from './HealthItemLink';
+import styles from './Health.css';
+
+const columns: Column[] = [
+ {
+ className: styles.status,
+ name: 'type',
+ label: '',
+ isVisible: true,
+ },
+ {
+ name: 'message',
+ label: () => translate('Message'),
+ isVisible: true,
+ },
+ {
+ name: 'actions',
+ label: () => translate('Actions'),
+ isVisible: true,
+ },
+];
+
+function Health() {
+ const dispatch = useDispatch();
+ const { isFetching, isPopulated, items } = useSelector(
+ createHealthSelector()
+ );
+ const isTestingAllApplications = useSelector(
+ (state: AppState) => state.settings.applications.isTestingAll
+ );
+ const isTestingAllDownloadClients = useSelector(
+ (state: AppState) => state.settings.downloadClients.isTestingAll
+ );
+ const isTestingAllIndexers = useSelector(
+ (state: AppState) => state.indexers.isTestingAll
+ );
+
+ const healthIssues = !!items.length;
+
+ const handleTestAllApplicationsPress = useCallback(() => {
+ dispatch(testAllApplications());
+ }, [dispatch]);
+
+ const handleTestAllDownloadClientsPress = useCallback(() => {
+ dispatch(testAllDownloadClients());
+ }, [dispatch]);
+
+ const handleTestAllIndexersPress = useCallback(() => {
+ dispatch(testAllIndexers());
+ }, [dispatch]);
+
+ useEffect(() => {
+ dispatch(fetchHealth());
+ }, [dispatch]);
+
+ return (
+
+ {translate('Health')}
+
+ {isFetching && isPopulated ? (
+
+ ) : null}
+
+ }
+ >
+ {isFetching && !isPopulated ?