mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-04-17 21:44:48 -04:00
Compare commits
221 Commits
v1.5.1.342
...
v1.8.0.380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1927e1e0f | ||
|
|
630a4ce800 | ||
|
|
8b1dd78300 | ||
|
|
cab50b35aa | ||
|
|
eee1be784b | ||
|
|
269dc5688b | ||
|
|
9bed795c89 | ||
|
|
3b5f151252 | ||
|
|
b3a541c9ff | ||
|
|
bc90fa2d3f | ||
|
|
4b0a896434 | ||
|
|
6be0e08635 | ||
|
|
f618901048 | ||
|
|
809ed940e6 | ||
|
|
7b14c2ee66 | ||
|
|
4528d03931 | ||
|
|
e0b30d34b1 | ||
|
|
8edf483e69 | ||
|
|
cea6aae9e1 | ||
|
|
1697cee680 | ||
|
|
ce8c90a125 | ||
|
|
c8ad3d6edd | ||
|
|
ebe01913c2 | ||
|
|
07cb19f9f3 | ||
|
|
7f51c44829 | ||
|
|
07f816f9fd | ||
|
|
a4a50b880c | ||
|
|
79361d92cb | ||
|
|
ecda75152e | ||
|
|
37a4e7c228 | ||
|
|
1a66d23bfe | ||
|
|
a26aa4bd1e | ||
|
|
a5d83459e9 | ||
|
|
4bfaab4b21 | ||
|
|
5764950b10 | ||
|
|
470b57316a | ||
|
|
f546b9a3b0 | ||
|
|
cc28c90e39 | ||
|
|
6e21e892bc | ||
|
|
62d868f0e9 | ||
|
|
27b36fe501 | ||
|
|
fc80efd15f | ||
|
|
9b75ba6ca0 | ||
|
|
d42649c4df | ||
|
|
53adfb750c | ||
|
|
ac487f9b40 | ||
|
|
6dd354bf1a | ||
|
|
b747d0a321 | ||
|
|
0e6cec6f54 | ||
|
|
65cf7c1009 | ||
|
|
5f9c3585f4 | ||
|
|
a9d1d4be90 | ||
|
|
a94ed11b21 | ||
|
|
3fab8fb0db | ||
|
|
5e52627799 | ||
|
|
b9a28f243e | ||
|
|
146e7ca7b6 | ||
|
|
1488fb7570 | ||
|
|
0fc52ae16f | ||
|
|
5218bea705 | ||
|
|
ac33330c7c | ||
|
|
041a7c571f | ||
|
|
5d73c6aa91 | ||
|
|
ef9a3a4f2a | ||
|
|
3ce3f8acdd | ||
|
|
9bac2992b5 | ||
|
|
4a88b70f40 | ||
|
|
c9b1d0d958 | ||
|
|
a5b5e7a3a5 | ||
|
|
376202e2af | ||
|
|
6b698b33be | ||
|
|
1706728230 | ||
|
|
cb520b2264 | ||
|
|
193335e2a8 | ||
|
|
1c98727cf3 | ||
|
|
ab5b321385 | ||
|
|
96340909f1 | ||
|
|
bd6a37dc8c | ||
|
|
a663cebada | ||
|
|
2ce5618499 | ||
|
|
94c91d4c3f | ||
|
|
79fbb2d0d7 | ||
|
|
e2e52746bb | ||
|
|
21cc96d683 | ||
|
|
e68b45636e | ||
|
|
ce68fe4105 | ||
|
|
712404ddca | ||
|
|
826828e8ec | ||
|
|
252740519f | ||
|
|
062fd77e1b | ||
|
|
6769055b6b | ||
|
|
90e92c0b66 | ||
|
|
7eac11f57a | ||
|
|
02a3c1b224 | ||
|
|
57efa6d0b1 | ||
|
|
cee52147bc | ||
|
|
a1abcd6c93 | ||
|
|
18e2757d37 | ||
|
|
8790a6f06a | ||
|
|
4fafdb2cd2 | ||
|
|
bfc06fc8bc | ||
|
|
9f4f6a5726 | ||
|
|
d9ace9a862 | ||
|
|
95691c7476 | ||
|
|
90f2020e59 | ||
|
|
6afa1dc8ba | ||
|
|
e8139f2a5b | ||
|
|
45328db2c7 | ||
|
|
e55d6b827a | ||
|
|
34cd68fa07 | ||
|
|
aed3f9f887 | ||
|
|
6880e67507 | ||
|
|
e0e1b1494e | ||
|
|
20df31919d | ||
|
|
8785fe02e8 | ||
|
|
b2b877a8c3 | ||
|
|
0de302ad48 | ||
|
|
06391489cf | ||
|
|
8fcceb0702 | ||
|
|
f20319fff1 | ||
|
|
20bcc00662 | ||
|
|
c4af3e746f | ||
|
|
660a162b7e | ||
|
|
20a3cad7fb | ||
|
|
77fe3f78fe | ||
|
|
d777cb8e29 | ||
|
|
15e7cc7ea8 | ||
|
|
04cf061275 | ||
|
|
d4cdeac69a | ||
|
|
e60fe05ee0 | ||
|
|
9a4c23797a | ||
|
|
acfdb5bae3 | ||
|
|
e2e65627ee | ||
|
|
4b8906ea62 | ||
|
|
f0c5d8ceea | ||
|
|
427802a50e | ||
|
|
0c9eae244a | ||
|
|
75ff2f41d3 | ||
|
|
d1ba208243 | ||
|
|
4e03ebadc4 | ||
|
|
0155ff60fd | ||
|
|
f0915638f3 | ||
|
|
56eb58aed1 | ||
|
|
8a891d07cf | ||
|
|
40a932cd28 | ||
|
|
4a81630073 | ||
|
|
0ff0fe2e68 | ||
|
|
51e33740b0 | ||
|
|
119164f729 | ||
|
|
ef0f8e25fd | ||
|
|
d21debe77f | ||
|
|
a3ccc3d0cf | ||
|
|
46d930e903 | ||
|
|
4561859c2b | ||
|
|
83166fb0b5 | ||
|
|
b98f9a945d | ||
|
|
e658e3fe48 | ||
|
|
9042525f22 | ||
|
|
7b551a0af1 | ||
|
|
31c2917bad | ||
|
|
419cce53f7 | ||
|
|
48cd1d9f6b | ||
|
|
8bd6a313b7 | ||
|
|
7cb465787e | ||
|
|
0b610ff9c8 | ||
|
|
5187460298 | ||
|
|
f0d9b43480 | ||
|
|
a1081cc554 | ||
|
|
c4bb1ba69a | ||
|
|
3a4c8db98c | ||
|
|
a522796798 | ||
|
|
e012eda0cf | ||
|
|
72ab2b34c4 | ||
|
|
aaba5b7499 | ||
|
|
455b76c45c | ||
|
|
596d3297da | ||
|
|
d05128ca33 | ||
|
|
f5b57db753 | ||
|
|
f7d7cca982 | ||
|
|
7c5409383e | ||
|
|
98db8f8bf8 | ||
|
|
88e793d76d | ||
|
|
0f31af6b89 | ||
|
|
65adf30f59 | ||
|
|
da75519524 | ||
|
|
ed1fb58242 | ||
|
|
d5daf6791c | ||
|
|
1f1a345d25 | ||
|
|
76a2f51533 | ||
|
|
8c0bc9ab4e | ||
|
|
b0c2b9119b | ||
|
|
87fdf17926 | ||
|
|
0f1b466a19 | ||
|
|
ea635e685b | ||
|
|
73f23d56dc | ||
|
|
f14ccebf3a | ||
|
|
9539e4d481 | ||
|
|
e40ccc49ad | ||
|
|
9fd3eb4d6b | ||
|
|
78aab80703 | ||
|
|
868394d588 | ||
|
|
d5e5697db8 | ||
|
|
d1e39f206a | ||
|
|
b59d89f308 | ||
|
|
bf5855beb4 | ||
|
|
2d36adf865 | ||
|
|
ef1ad59f59 | ||
|
|
59b6e8af27 | ||
|
|
3ae1917d3b | ||
|
|
5864a090e4 | ||
|
|
fcfec1b859 | ||
|
|
65541017dd | ||
|
|
7fe9942c28 | ||
|
|
360827708f | ||
|
|
0509335387 | ||
|
|
f54212a809 | ||
|
|
ea0eb2efa7 | ||
|
|
ce430433e5 | ||
|
|
5437aac346 | ||
|
|
b02188acf4 | ||
|
|
6897ed0b3f |
@@ -36,12 +36,18 @@ dotnet_naming_style.instance_field_style.capitalization = camel_case
|
||||
dotnet_naming_style.instance_field_style.required_prefix = _
|
||||
|
||||
# Prefer "var" everywhere
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
csharp_style_var_for_built_in_types = true
|
||||
csharp_style_var_when_type_is_apparent = true
|
||||
csharp_style_var_elsewhere = true
|
||||
# Prefer "out" variables to be declared inline
|
||||
csharp_style_inlined_variable_declaration = true
|
||||
|
||||
# Using directive is unnecessary.
|
||||
dotnet_diagnostic.IDE0005.severity = error
|
||||
# Use var instead of explicit type
|
||||
dotnet_diagnostic.IDE0007.severity = error
|
||||
# Inline variable declaration
|
||||
dotnet_diagnostic.IDE0018.severity = error
|
||||
|
||||
# Stylecop Rules
|
||||
dotnet_diagnostic.SA0001.severity = none
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -74,7 +74,7 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Trace Logs have been provided as applicable. Reports may be closed if the required logs are not provided.
|
||||
description: Trace logs are generally required for all bug reports
|
||||
description: Trace logs are generally required for all bug reports and contain `trace`. Info logs are invalid for bug reports and do not contain `debug` nor `trace`
|
||||
options:
|
||||
- label: I have followed the steps in the wiki link above and provided the required trace logs that are relevant and show this issue.
|
||||
- label: I have read and followed the steps in the wiki link above and provided the required trace logs - the logs contain `trace` - that are relevant and show this issue.
|
||||
required: true
|
||||
|
||||
2
.github/label-actions.yml
vendored
2
.github/label-actions.yml
vendored
@@ -7,6 +7,7 @@
|
||||
to be a support request. Please hop over onto our [Discord](https://prowlarr.com/discord)
|
||||
or [Subreddit](https://reddit.com/r/prowlarr)
|
||||
close: true
|
||||
close-reason: 'not planned'
|
||||
|
||||
'Type: Indexer Request':
|
||||
comment: >
|
||||
@@ -14,6 +15,7 @@
|
||||
for bug reports and feature requests. However, this issue appears
|
||||
to be a indexer request. Please use our Indexer request [site](https://requests.prowlarr.com/)
|
||||
close: true
|
||||
close-reason: 'not planned'
|
||||
|
||||
'Status: Logs Needed':
|
||||
comment: >
|
||||
|
||||
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '1.5.1'
|
||||
majorVersion: '1.8.0'
|
||||
minorVersion: $[counter('minorVersion', 1)]
|
||||
prowlarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
|
||||
@@ -362,7 +362,7 @@ stages:
|
||||
- bash: |
|
||||
echo "Uploading source maps to sentry"
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
RELEASENAME="${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
|
||||
RELEASENAME="Prowlarr@${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
|
||||
sentry-cli releases new --finalize -p prowlarr -p prowlarr-ui -p prowlarr-update "${RELEASENAME}"
|
||||
sentry-cli releases -p prowlarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite
|
||||
sentry-cli releases set-commits --auto "${RELEASENAME}"
|
||||
@@ -1003,7 +1003,7 @@ stages:
|
||||
git add .
|
||||
if git status | grep -q modified
|
||||
then
|
||||
git commit -am 'Automated API Docs update'
|
||||
git commit -am 'Automated API Docs update [skip ci]'
|
||||
git push -f --set-upstream origin api-docs
|
||||
curl -X POST -H "Authorization: token ${GITHUBTOKEN}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/prowlarr/prowlarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
|
||||
else
|
||||
|
||||
@@ -26,7 +26,8 @@ module.exports = {
|
||||
globals: {
|
||||
expect: false,
|
||||
chai: false,
|
||||
sinon: false
|
||||
sinon: false,
|
||||
JSX: true
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
|
||||
@@ -65,23 +65,23 @@ module.exports = (env) => {
|
||||
output: {
|
||||
path: distFolder,
|
||||
publicPath: '/',
|
||||
filename: '[name].js',
|
||||
filename: '[name]-[contenthash].js',
|
||||
sourceMapFilename: '[file].map'
|
||||
},
|
||||
|
||||
optimization: {
|
||||
moduleIds: 'deterministic',
|
||||
chunkIds: 'named',
|
||||
splitChunks: {
|
||||
chunks: 'initial',
|
||||
name: 'vendors'
|
||||
}
|
||||
chunkIds: isProduction ? 'deterministic' : 'named'
|
||||
},
|
||||
|
||||
performance: {
|
||||
hints: false
|
||||
},
|
||||
|
||||
experiments: {
|
||||
topLevelAwait: true
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
__DEV__: !isProduction,
|
||||
@@ -89,7 +89,8 @@ module.exports = (env) => {
|
||||
}),
|
||||
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'Content/styles.css'
|
||||
filename: 'Content/styles.css',
|
||||
chunkFilename: 'Content/[id]-[chunkhash].css'
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
|
||||
@@ -7,13 +7,13 @@ import PageConnector from 'Components/Page/PageConnector';
|
||||
import ApplyTheme from './ApplyTheme';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
function App({ store, history }) {
|
||||
function App({ store, history, hasTranslationsError }) {
|
||||
return (
|
||||
<DocumentTitle title={window.Prowlarr.instanceName}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme>
|
||||
<PageConnector>
|
||||
<PageConnector hasTranslationsError={hasTranslationsError}>
|
||||
<AppRoutes app={App} />
|
||||
</PageConnector>
|
||||
</ApplyTheme>
|
||||
@@ -25,7 +25,8 @@ function App({ store, history }) {
|
||||
|
||||
App.propTypes = {
|
||||
store: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired
|
||||
history: PropTypes.object.isRequired,
|
||||
hasTranslationsError: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -5,7 +5,7 @@ import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import HistoryConnector from 'History/HistoryConnector';
|
||||
import IndexerIndex from 'Indexer/Index/IndexerIndex';
|
||||
import StatsConnector from 'Indexer/Stats/StatsConnector';
|
||||
import IndexerStats from 'Indexer/Stats/IndexerStats';
|
||||
import SearchIndexConnector from 'Search/SearchIndexConnector';
|
||||
import ApplicationSettingsConnector from 'Settings/Applications/ApplicationSettingsConnector';
|
||||
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
|
||||
@@ -60,7 +60,7 @@ function AppRoutes(props) {
|
||||
|
||||
<Route
|
||||
path="/indexers/stats"
|
||||
component={StatsConnector}
|
||||
component={IndexerStats}
|
||||
/>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -1,58 +1,28 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
import areAllSelected from 'Utilities/Table/areAllSelected';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
|
||||
import ModelBase from './ModelBase';
|
||||
|
||||
export enum SelectActionType {
|
||||
Reset,
|
||||
SelectAll,
|
||||
UnselectAll,
|
||||
ToggleSelected,
|
||||
RemoveItem,
|
||||
UpdateItems,
|
||||
}
|
||||
|
||||
type SelectedState = Record<number, boolean>;
|
||||
|
||||
interface SelectState {
|
||||
selectedState: SelectedState;
|
||||
lastToggled: number | null;
|
||||
allSelected: boolean;
|
||||
allUnselected: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
items: any[];
|
||||
}
|
||||
|
||||
type SelectAction =
|
||||
| { type: SelectActionType.Reset }
|
||||
| { type: SelectActionType.SelectAll }
|
||||
| { type: SelectActionType.UnselectAll }
|
||||
export type SelectContextAction =
|
||||
| { type: 'reset' }
|
||||
| { type: 'selectAll' }
|
||||
| { type: 'unselectAll' }
|
||||
| {
|
||||
type: SelectActionType.ToggleSelected;
|
||||
type: 'toggleSelected';
|
||||
id: number;
|
||||
isSelected: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
| {
|
||||
type: SelectActionType.RemoveItem;
|
||||
type: 'removeItem';
|
||||
id: number;
|
||||
}
|
||||
| {
|
||||
type: SelectActionType.UpdateItems;
|
||||
type: 'updateItems';
|
||||
items: ModelBase[];
|
||||
};
|
||||
|
||||
type Dispatch = (action: SelectAction) => void;
|
||||
|
||||
const initialState = {
|
||||
selectedState: {},
|
||||
lastToggled: null,
|
||||
allSelected: false,
|
||||
allUnselected: true,
|
||||
items: [],
|
||||
};
|
||||
export type SelectDispatch = (action: SelectContextAction) => void;
|
||||
|
||||
interface SelectProviderOptions<T extends ModelBase> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -60,90 +30,40 @@ interface SelectProviderOptions<T extends ModelBase> {
|
||||
items: Array<T>;
|
||||
}
|
||||
|
||||
function getSelectedState(items: ModelBase[], existingState: SelectedState) {
|
||||
return items.reduce((acc: SelectedState, item) => {
|
||||
const id = item.id;
|
||||
|
||||
acc[id] = existingState[id] ?? false;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// TODO: Can this be reused?
|
||||
|
||||
const SelectContext = React.createContext<[SelectState, Dispatch] | undefined>(
|
||||
cloneDeep(undefined)
|
||||
);
|
||||
|
||||
function selectReducer(state: SelectState, action: SelectAction): SelectState {
|
||||
const { items, selectedState } = state;
|
||||
|
||||
switch (action.type) {
|
||||
case SelectActionType.Reset: {
|
||||
return cloneDeep(initialState);
|
||||
}
|
||||
case SelectActionType.SelectAll: {
|
||||
return {
|
||||
items,
|
||||
...selectAll(selectedState, true),
|
||||
};
|
||||
}
|
||||
case SelectActionType.UnselectAll: {
|
||||
return {
|
||||
items,
|
||||
...selectAll(selectedState, false),
|
||||
};
|
||||
}
|
||||
case SelectActionType.ToggleSelected: {
|
||||
const result = {
|
||||
items,
|
||||
...toggleSelected(
|
||||
state,
|
||||
items,
|
||||
action.id,
|
||||
action.isSelected,
|
||||
action.shiftKey
|
||||
),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
case SelectActionType.UpdateItems: {
|
||||
const nextSelectedState = getSelectedState(action.items, selectedState);
|
||||
|
||||
return {
|
||||
...state,
|
||||
...areAllSelected(nextSelectedState),
|
||||
selectedState: nextSelectedState,
|
||||
items: action.items,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled action type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const SelectContext = React.createContext<
|
||||
[SelectState, SelectDispatch] | undefined
|
||||
>(cloneDeep(undefined));
|
||||
|
||||
export function SelectProvider<T extends ModelBase>(
|
||||
props: SelectProviderOptions<T>
|
||||
) {
|
||||
const { items } = props;
|
||||
const selectedState = getSelectedState(items, {});
|
||||
const [state, dispatch] = useSelectState();
|
||||
|
||||
const [state, dispatch] = React.useReducer(selectReducer, {
|
||||
selectedState,
|
||||
lastToggled: null,
|
||||
allSelected: false,
|
||||
allUnselected: true,
|
||||
items,
|
||||
});
|
||||
const dispatchWrapper = useCallback(
|
||||
(action: SelectContextAction) => {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
case 'removeItem':
|
||||
dispatch(action);
|
||||
break;
|
||||
|
||||
const value: [SelectState, Dispatch] = [state, dispatch];
|
||||
default:
|
||||
dispatch({
|
||||
...action,
|
||||
items,
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: SelectActionType.UpdateItems, items });
|
||||
}, [items]);
|
||||
dispatch({ type: 'updateItems', items });
|
||||
}, [items, dispatch]);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={value}>
|
||||
|
||||
48
frontend/src/App/State/AppSectionState.ts
Normal file
48
frontend/src/App/State/AppSectionState.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
|
||||
export interface Error {
|
||||
responseJSON: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppSectionDeleteState {
|
||||
isDeleting: boolean;
|
||||
deleteError: Error;
|
||||
}
|
||||
|
||||
export interface AppSectionSaveState {
|
||||
isSaving: boolean;
|
||||
saveError: Error;
|
||||
}
|
||||
|
||||
export interface PagedAppSectionState {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AppSectionSchemaState<T> {
|
||||
isSchemaFetching: boolean;
|
||||
isSchemaPopulated: boolean;
|
||||
schemaError: Error;
|
||||
schema: {
|
||||
items: T[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppSectionItemState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
item: T;
|
||||
}
|
||||
|
||||
interface AppSectionState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
}
|
||||
|
||||
export default AppSectionState;
|
||||
52
frontend/src/App/State/AppState.ts
Normal file
52
frontend/src/App/State/AppState.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import CommandAppState from './CommandAppState';
|
||||
import IndexerAppState, {
|
||||
IndexerIndexAppState,
|
||||
IndexerStatusAppState,
|
||||
} from './IndexerAppState';
|
||||
import IndexerStatsAppState from './IndexerStatsAppState';
|
||||
import SettingsAppState from './SettingsAppState';
|
||||
import TagsAppState from './TagsAppState';
|
||||
|
||||
interface FilterBuilderPropOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FilterBuilderProp<T> {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
valueType?: string;
|
||||
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
|
||||
}
|
||||
|
||||
export interface PropertyFilter {
|
||||
key: string;
|
||||
value: boolean | string | number | string[] | number[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
key: string;
|
||||
label: string;
|
||||
filers: PropertyFilter[];
|
||||
}
|
||||
|
||||
export interface CustomFilter {
|
||||
id: number;
|
||||
type: string;
|
||||
label: string;
|
||||
filers: PropertyFilter[];
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
commands: CommandAppState;
|
||||
indexerIndex: IndexerIndexAppState;
|
||||
indexerStats: IndexerStatsAppState;
|
||||
indexerStatus: IndexerStatusAppState;
|
||||
indexers: IndexerAppState;
|
||||
settings: SettingsAppState;
|
||||
tags: TagsAppState;
|
||||
}
|
||||
|
||||
export default AppState;
|
||||
8
frontend/src/App/State/ClientSideCollectionAppState.ts
Normal file
8
frontend/src/App/State/ClientSideCollectionAppState.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CustomFilter } from './AppState';
|
||||
|
||||
interface ClientSideCollectionAppState {
|
||||
totalItems: number;
|
||||
customFilters: CustomFilter[];
|
||||
}
|
||||
|
||||
export default ClientSideCollectionAppState;
|
||||
6
frontend/src/App/State/CommandAppState.ts
Normal file
6
frontend/src/App/State/CommandAppState.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Command from 'Commands/Command';
|
||||
|
||||
export type CommandAppState = AppSectionState<Command>;
|
||||
|
||||
export default CommandAppState;
|
||||
35
frontend/src/App/State/IndexerAppState.ts
Normal file
35
frontend/src/App/State/IndexerAppState.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import Indexer, { IndexerStatus } from 'Indexer/Indexer';
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
} from './AppSectionState';
|
||||
import { Filter, FilterBuilderProp } from './AppState';
|
||||
|
||||
export interface IndexerIndexAppState {
|
||||
isTestingAll: boolean;
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
secondarySortKey: string;
|
||||
secondarySortDirection: SortDirection;
|
||||
view: string;
|
||||
|
||||
tableOptions: {
|
||||
showSearchAction: boolean;
|
||||
};
|
||||
|
||||
selectedFilterKey: string;
|
||||
filterBuilderProps: FilterBuilderProp<Indexer>[];
|
||||
filters: Filter[];
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
interface IndexerAppState
|
||||
extends AppSectionState<Indexer>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export type IndexerStatusAppState = AppSectionState<IndexerStatus>;
|
||||
|
||||
export default IndexerAppState;
|
||||
11
frontend/src/App/State/IndexerStatsAppState.ts
Normal file
11
frontend/src/App/State/IndexerStatsAppState.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AppSectionItemState } from 'App/State/AppSectionState';
|
||||
import { Filter } from 'App/State/AppState';
|
||||
import { IndexerStats } from 'typings/IndexerStats';
|
||||
|
||||
export interface IndexerStatsAppState
|
||||
extends AppSectionItemState<IndexerStats> {
|
||||
selectedFilterKey: string;
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
export default IndexerStatsAppState;
|
||||
39
frontend/src/App/State/SettingsAppState.ts
Normal file
39
frontend/src/App/State/SettingsAppState.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Application from 'typings/Application';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import Notification from 'typings/Notification';
|
||||
import { UiSettings } from 'typings/UiSettings';
|
||||
|
||||
export interface AppProfileAppState
|
||||
extends AppSectionState<Application>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ApplicationAppState
|
||||
extends AppSectionState<Application>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface NotificationAppState
|
||||
extends AppSectionState<Notification>,
|
||||
AppSectionDeleteState {}
|
||||
|
||||
export type UiSettingsAppState = AppSectionState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
appProfiles: AppProfileAppState;
|
||||
applications: ApplicationAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
notifications: NotificationAppState;
|
||||
uiSettings: UiSettingsAppState;
|
||||
}
|
||||
|
||||
export default SettingsAppState;
|
||||
12
frontend/src/App/State/TagsAppState.ts
Normal file
12
frontend/src/App/State/TagsAppState.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
} from 'App/State/AppSectionState';
|
||||
|
||||
export interface Tag extends ModelBase {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
|
||||
|
||||
export default TagsAppState;
|
||||
37
frontend/src/Commands/Command.ts
Normal file
37
frontend/src/Commands/Command.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export interface CommandBody {
|
||||
sendUpdatesToClient: boolean;
|
||||
updateScheduledTask: boolean;
|
||||
completionMessage: string;
|
||||
requiresDiskAccess: boolean;
|
||||
isExclusive: boolean;
|
||||
isLongRunning: boolean;
|
||||
name: string;
|
||||
lastExecutionTime: string;
|
||||
lastStartTime: string;
|
||||
trigger: string;
|
||||
suppressMessages: boolean;
|
||||
seriesId?: number;
|
||||
}
|
||||
|
||||
interface Command extends ModelBase {
|
||||
name: string;
|
||||
commandName: string;
|
||||
message: string;
|
||||
body: CommandBody;
|
||||
priority: string;
|
||||
status: string;
|
||||
result: string;
|
||||
queued: string;
|
||||
started: string;
|
||||
ended: string;
|
||||
duration: string;
|
||||
trigger: string;
|
||||
stateChangeTime: string;
|
||||
sendUpdatesToClient: boolean;
|
||||
updateScheduledTask: boolean;
|
||||
lastExecutionTime: string;
|
||||
}
|
||||
|
||||
export default Command;
|
||||
@@ -23,7 +23,9 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
|
||||
info,
|
||||
} = props;
|
||||
|
||||
const [detailedError, setDetailedError] = useState(null);
|
||||
const [detailedError, setDetailedError] = useState<
|
||||
StackTrace.StackFrame[] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
|
||||
@@ -202,7 +202,7 @@ class FilterBuilderRow extends Component {
|
||||
key: availablePropFilter.name,
|
||||
value: availablePropFilter.label
|
||||
};
|
||||
});
|
||||
}).sort((a, b) => a.value.localeCompare(b.value));
|
||||
|
||||
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.indexers,
|
||||
(qualityProfiles) => {
|
||||
(indexers) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = qualityProfiles;
|
||||
} = indexers;
|
||||
|
||||
const tagList = items.map((item) => {
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
@@ -23,7 +24,7 @@ function createMapStateToProps() {
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
value: translate('NoChange'),
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.downloadClients,
|
||||
(state, { includeAny }) => includeAny,
|
||||
(state, { protocol }) => protocol,
|
||||
(downloadClients, includeAny, protocolFilter) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = downloadClients;
|
||||
|
||||
const values = items
|
||||
.filter((downloadClient) => downloadClient.protocol === protocolFilter)
|
||||
.sort(sortByName)
|
||||
.map((downloadClient) => ({
|
||||
key: downloadClient.id,
|
||||
value: downloadClient.name
|
||||
}));
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: '(Any)'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
values
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchDownloadClients: fetchDownloadClients
|
||||
};
|
||||
|
||||
class DownloadClientSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.dispatchFetchDownloadClients();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
this.props.onChange({ name, value: parseInt(value) });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DownloadClientSelectInputConnector.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
includeAny: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
dispatchFetchDownloadClients: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
DownloadClientSelectInputConnector.defaultProps = {
|
||||
includeAny: false,
|
||||
protocol: 'torrent'
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector);
|
||||
@@ -578,7 +578,7 @@ EnhancedSelectInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
disabledClassName: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -10,6 +10,7 @@ import CaptchaInputConnector from './CaptchaInputConnector';
|
||||
import CardigannCaptchaInputConnector from './CardigannCaptchaInputConnector';
|
||||
import CheckInput from './CheckInput';
|
||||
import DeviceInputConnector from './DeviceInputConnector';
|
||||
import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
@@ -72,6 +73,9 @@ function getComponent(type) {
|
||||
case inputTypes.CATEGORY_SELECT:
|
||||
return NewznabCategorySelectInputConnector;
|
||||
|
||||
case inputTypes.DOWNLOAD_CLIENT_SELECT:
|
||||
return DownloadClientSelectInputConnector;
|
||||
|
||||
case inputTypes.INDEXER_FLAGS_SELECT:
|
||||
return IndexerFlagsSelectInputConnector;
|
||||
|
||||
@@ -258,12 +262,15 @@ FormInputGroup.propTypes = {
|
||||
values: PropTypes.arrayOf(PropTypes.any),
|
||||
type: PropTypes.string.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
min: PropTypes.number,
|
||||
max: PropTypes.number,
|
||||
unit: PropTypes.string,
|
||||
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||
helpText: PropTypes.string,
|
||||
helpTexts: PropTypes.arrayOf(PropTypes.string),
|
||||
helpTextWarning: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
autoFocus: PropTypes.bool,
|
||||
includeNoChange: PropTypes.bool,
|
||||
includeNoChangeDisabled: PropTypes.bool,
|
||||
selectedValueOptions: PropTypes.object,
|
||||
|
||||
@@ -37,7 +37,7 @@ function HintedSelectInputOption(props) {
|
||||
|
||||
{
|
||||
hint != null &&
|
||||
<div className={styles.hintText}>
|
||||
<div className={styles.hintText} title={hint}>
|
||||
{hint}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function HintedSelectInputSelectedValue(props) {
|
||||
>
|
||||
<div className={styles.valueText}>
|
||||
{
|
||||
isMultiSelect &&
|
||||
isMultiSelect ?
|
||||
value.map((key, index) => {
|
||||
const v = valuesMap[key];
|
||||
return (
|
||||
@@ -32,26 +32,28 @@ function HintedSelectInputSelectedValue(props) {
|
||||
{v ? v.value : key}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}) :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isMultiSelect && value
|
||||
isMultiSelect ? null : value
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
hint != null && includeHint &&
|
||||
hint != null && includeHint ?
|
||||
<div className={styles.hintText}>
|
||||
{hint}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
HintedSelectInputSelectedValue.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hint: PropTypes.string,
|
||||
isMultiSelect: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import _ from 'lodash';
|
||||
import { groupBy, map } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { value }) => value,
|
||||
(state) => state.indexers,
|
||||
createSortedSectionSelector('indexers', sortByName),
|
||||
(value, indexers) => {
|
||||
const values = [];
|
||||
const groupedIndexers = _(indexers.items).groupBy((x) => x.protocol).map((val, key) => ({ protocol: key, indexers: val })).value();
|
||||
const groupedIndexers = map(groupBy(indexers.items, 'protocol'), (val, key) => ({ protocol: key, indexers: val }));
|
||||
|
||||
groupedIndexers.forEach((element) => {
|
||||
values.push({
|
||||
@@ -21,10 +23,12 @@ function createMapStateToProps() {
|
||||
});
|
||||
|
||||
if (element.indexers && element.indexers.length > 0) {
|
||||
element.indexers.forEach((subCat) => {
|
||||
element.indexers.forEach((indexer) => {
|
||||
values.push({
|
||||
key: subCat.id,
|
||||
value: subCat.name,
|
||||
key: indexer.id,
|
||||
value: indexer.name,
|
||||
hint: `(${indexer.id})`,
|
||||
isDisabled: !indexer.enable,
|
||||
parentKey: element.protocol === 'usenet' ? -1 : -2
|
||||
});
|
||||
});
|
||||
@@ -49,7 +53,6 @@ class IndexersSelectInputConnector extends Component {
|
||||
// Render
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
|
||||
@@ -10,7 +10,7 @@ function parseValue(props, value) {
|
||||
} = props;
|
||||
|
||||
if (value == null || value === '') {
|
||||
return min;
|
||||
return null;
|
||||
}
|
||||
|
||||
let newValue = isFloat ? parseFloat(value) : parseInt(value);
|
||||
|
||||
@@ -68,6 +68,7 @@ class PathInputConnector extends Component {
|
||||
}
|
||||
|
||||
PathInputConnector.propTypes = {
|
||||
...PathInput.props,
|
||||
includeFiles: PropTypes.bool.isRequired,
|
||||
dispatchFetchPaths: PropTypes.func.isRequired,
|
||||
dispatchClearPaths: PropTypes.func.isRequired
|
||||
|
||||
@@ -67,6 +67,7 @@ function ProviderFieldFormGroup(props) {
|
||||
name,
|
||||
label,
|
||||
helpText,
|
||||
helpTextWarning,
|
||||
helpLink,
|
||||
placeholder,
|
||||
value,
|
||||
@@ -100,6 +101,7 @@ function ProviderFieldFormGroup(props) {
|
||||
name={name}
|
||||
label={label}
|
||||
helpText={helpText}
|
||||
helpTextWarning={helpTextWarning}
|
||||
helpLink={helpLink}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
@@ -126,6 +128,7 @@ ProviderFieldFormGroup.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string,
|
||||
helpText: PropTypes.string,
|
||||
helpTextWarning: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.any,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import styles from './Link.css';
|
||||
|
||||
class Link extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onClick = (event) => {
|
||||
const {
|
||||
isDisabled,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (!isDisabled && onPress) {
|
||||
onPress(event);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
component,
|
||||
to,
|
||||
target,
|
||||
isDisabled,
|
||||
noRouter,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const linkProps = { target };
|
||||
let el = component;
|
||||
|
||||
if (to) {
|
||||
if ((/\w+?:\/\//).test(to)) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_blank';
|
||||
linkProps.rel = 'noreferrer';
|
||||
} else if (noRouter) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_self';
|
||||
} else {
|
||||
el = RouterLink;
|
||||
linkProps.to = `${window.Prowlarr.urlBase}/${to.replace(/^\//, '')}`;
|
||||
linkProps.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
if (el === 'button' || el === 'input') {
|
||||
linkProps.type = otherProps.type || 'button';
|
||||
linkProps.disabled = isDisabled;
|
||||
}
|
||||
|
||||
linkProps.className = classNames(
|
||||
className,
|
||||
styles.link,
|
||||
to && styles.to,
|
||||
isDisabled && 'isDisabled'
|
||||
);
|
||||
|
||||
const props = {
|
||||
...otherProps,
|
||||
...linkProps
|
||||
};
|
||||
|
||||
props.onClick = this.onClick;
|
||||
|
||||
return (
|
||||
React.createElement(el, props)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Link.propTypes = {
|
||||
className: PropTypes.string,
|
||||
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
to: PropTypes.string,
|
||||
target: PropTypes.string,
|
||||
isDisabled: PropTypes.bool,
|
||||
noRouter: PropTypes.bool,
|
||||
onPress: PropTypes.func
|
||||
};
|
||||
|
||||
Link.defaultProps = {
|
||||
component: 'button',
|
||||
noRouter: false
|
||||
};
|
||||
|
||||
export default Link;
|
||||
96
frontend/src/Components/Link/Link.tsx
Normal file
96
frontend/src/Components/Link/Link.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
ComponentClass,
|
||||
FunctionComponent,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import styles from './Link.css';
|
||||
|
||||
interface ReactRouterLinkProps {
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
|
||||
className?: string;
|
||||
component?:
|
||||
| string
|
||||
| FunctionComponent<LinkProps>
|
||||
| ComponentClass<LinkProps, unknown>;
|
||||
to?: string;
|
||||
target?: string;
|
||||
isDisabled?: boolean;
|
||||
noRouter?: boolean;
|
||||
onPress?(event: SyntheticEvent): void;
|
||||
}
|
||||
function Link(props: LinkProps) {
|
||||
const {
|
||||
className,
|
||||
component = 'button',
|
||||
to,
|
||||
target,
|
||||
type,
|
||||
isDisabled,
|
||||
noRouter = false,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const onClick = useCallback(
|
||||
(event: SyntheticEvent) => {
|
||||
if (!isDisabled && onPress) {
|
||||
onPress(event);
|
||||
}
|
||||
},
|
||||
[isDisabled, onPress]
|
||||
);
|
||||
|
||||
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
|
||||
target,
|
||||
};
|
||||
let el = component;
|
||||
|
||||
if (to) {
|
||||
if (/\w+?:\/\//.test(to)) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_blank';
|
||||
linkProps.rel = 'noreferrer';
|
||||
} else if (noRouter) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_self';
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
el = RouterLink;
|
||||
linkProps.to = `${window.Prowlarr.urlBase}/${to.replace(/^\//, '')}`;
|
||||
linkProps.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
if (el === 'button' || el === 'input') {
|
||||
linkProps.type = type || 'button';
|
||||
linkProps.disabled = isDisabled;
|
||||
}
|
||||
|
||||
linkProps.className = classNames(
|
||||
className,
|
||||
styles.link,
|
||||
to && styles.to,
|
||||
isDisabled && 'isDisabled'
|
||||
);
|
||||
|
||||
const elementProps = {
|
||||
...otherProps,
|
||||
type,
|
||||
...linkProps,
|
||||
};
|
||||
|
||||
elementProps.onClick = onClick;
|
||||
|
||||
return React.createElement(el, elementProps);
|
||||
}
|
||||
|
||||
export default Link;
|
||||
@@ -7,6 +7,7 @@ function ErrorPage(props) {
|
||||
const {
|
||||
version,
|
||||
isLocalStorageSupported,
|
||||
hasTranslationsError,
|
||||
indexersError,
|
||||
indexerStatusError,
|
||||
indexerCategoriesError,
|
||||
@@ -21,6 +22,8 @@ function ErrorPage(props) {
|
||||
|
||||
if (!isLocalStorageSupported) {
|
||||
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
|
||||
} else if (hasTranslationsError) {
|
||||
errorMessage = 'Failed to load translations from API';
|
||||
} else if (indexersError) {
|
||||
errorMessage = getErrorMessage(indexersError, 'Failed to load indexers from API');
|
||||
} else if (indexerStatusError) {
|
||||
@@ -55,6 +58,7 @@ function ErrorPage(props) {
|
||||
ErrorPage.propTypes = {
|
||||
version: PropTypes.string.isRequired,
|
||||
isLocalStorageSupported: PropTypes.bool.isRequired,
|
||||
hasTranslationsError: PropTypes.bool.isRequired,
|
||||
indexersError: PropTypes.object,
|
||||
indexerStatusError: PropTypes.object,
|
||||
indexerCategoriesError: PropTypes.object,
|
||||
|
||||
@@ -232,6 +232,7 @@ class PageConnector extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
hasTranslationsError,
|
||||
isPopulated,
|
||||
hasError,
|
||||
dispatchFetchTags,
|
||||
@@ -245,11 +246,12 @@ class PageConnector extends Component {
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
if (hasError || !this.state.isLocalStorageSupported) {
|
||||
if (hasTranslationsError || hasError || !this.state.isLocalStorageSupported) {
|
||||
return (
|
||||
<ErrorPage
|
||||
{...this.state}
|
||||
{...otherProps}
|
||||
hasTranslationsError={hasTranslationsError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -270,6 +272,7 @@ class PageConnector extends Component {
|
||||
}
|
||||
|
||||
PageConnector.propTypes = {
|
||||
hasTranslationsError: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
hasError: PropTypes.bool.isRequired,
|
||||
isSidebarVisible: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import React, { forwardRef, ReactNode, useCallback } from 'react';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
|
||||
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import { isLocked } from 'Utilities/scrollLock';
|
||||
import styles from './PageContentBody.css';
|
||||
|
||||
interface PageContentBodyProps {
|
||||
className: string;
|
||||
innerClassName: string;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
children: ReactNode;
|
||||
initialScrollTop?: number;
|
||||
onScroll?: (payload) => void;
|
||||
onScroll?: (payload: OnScroll) => void;
|
||||
}
|
||||
|
||||
const PageContentBody = forwardRef(
|
||||
(
|
||||
props: PageContentBodyProps,
|
||||
ref: React.MutableRefObject<HTMLDivElement>
|
||||
) => {
|
||||
(props: PageContentBodyProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const {
|
||||
className = styles.contentBody,
|
||||
innerClassName = styles.innerContentBody,
|
||||
@@ -26,7 +23,7 @@ const PageContentBody = forwardRef(
|
||||
} = props;
|
||||
|
||||
const onScrollWrapper = useCallback(
|
||||
(payload) => {
|
||||
(payload: OnScroll) => {
|
||||
if (onScroll && !isLocked()) {
|
||||
onScroll(payload);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.jumpBar {
|
||||
z-index: $pageJumpBarZIndex;
|
||||
display: flex;
|
||||
align-content: stretch;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
|
||||
function PageSectionContent(props) {
|
||||
const {
|
||||
@@ -17,7 +19,7 @@ function PageSectionContent(props) {
|
||||
);
|
||||
} else if (!isFetching && !!error) {
|
||||
return (
|
||||
<div>{errorMessage}</div>
|
||||
<Alert kind={kinds.DANGER}>{errorMessage}</Alert>
|
||||
);
|
||||
} else if (isPopulated && !error) {
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,38 @@
|
||||
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
|
||||
color: var(--white);
|
||||
transition: width 0.6s ease;
|
||||
|
||||
&.primary {
|
||||
background-color: var(--primaryColor);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--dangerColor);
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, color(var(--dangerColor) shade(5%)), color(var(--dangerColor) shade(5%)) 5px, color(var(--dangerColor) shade(15%)) 5px, color(var(--dangerColor) shade(15%)) 10px);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: var(--successColor);
|
||||
}
|
||||
|
||||
&.purple {
|
||||
background-color: var(--purple);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-color: var(--warningColor);
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(45deg, var(--warningColor), var(--warningColor) 5px, color(var(--warningColor) tint(15%)) 5px, color(var(--warningColor) tint(15%)) 10px);
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
background-color: var(--infoColor);
|
||||
}
|
||||
}
|
||||
|
||||
.frontTextContainer {
|
||||
@@ -41,38 +73,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: var(--primaryColor);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: var(--dangerColor);
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, color(var(--dangerColor) shade(5%)), color(var(--dangerColor) shade(5%)) 5px, color(var(--dangerColor) shade(15%)) 5px, color(var(--dangerColor) shade(15%)) 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: var(--successColor);
|
||||
}
|
||||
|
||||
.purple {
|
||||
background-color: var(--purple);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: var(--warningColor);
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(45deg, var(--warningColor), var(--warningColor) 5px, color(var(--warningColor) tint(15%)) 5px, color(var(--warningColor) tint(15%)) 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: var(--infoColor);
|
||||
}
|
||||
|
||||
.small {
|
||||
height: $progressBarSmallHeight;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function ProgressBar(props) {
|
||||
{
|
||||
showText && width ?
|
||||
<div
|
||||
className={styles.backTextContainer}
|
||||
className={classNames(styles.backTextContainer, styles[kind])}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div className={styles.backText}>
|
||||
@@ -67,7 +67,7 @@ function ProgressBar(props) {
|
||||
{
|
||||
showText ?
|
||||
<div
|
||||
className={styles.frontTextContainer}
|
||||
className={classNames(styles.frontTextContainer, styles[kind])}
|
||||
style={{ width: progressPercent }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import classNames from 'classnames';
|
||||
import { throttle } from 'lodash';
|
||||
import React, { forwardRef, ReactNode, useEffect, useRef } from 'react';
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import styles from './Scroller.css';
|
||||
|
||||
export interface OnScroll {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface ScrollerProps {
|
||||
className?: string;
|
||||
scrollDirection?: ScrollDirection;
|
||||
@@ -12,11 +24,11 @@ interface ScrollerProps {
|
||||
scrollTop?: number;
|
||||
initialScrollTop?: number;
|
||||
children?: ReactNode;
|
||||
onScroll?: (payload) => void;
|
||||
onScroll?: (payload: OnScroll) => void;
|
||||
}
|
||||
|
||||
const Scroller = forwardRef(
|
||||
(props: ScrollerProps, ref: React.MutableRefObject<HTMLDivElement>) => {
|
||||
(props: ScrollerProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const {
|
||||
className,
|
||||
autoFocus = false,
|
||||
@@ -30,7 +42,7 @@ const Scroller = forwardRef(
|
||||
} = props;
|
||||
|
||||
const internalRef = useRef();
|
||||
const currentRef = ref ?? internalRef;
|
||||
const currentRef = (ref as MutableRefObject<HTMLDivElement>) ?? internalRef;
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
|
||||
7
frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
vendored
Normal file
7
frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'cell': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -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 (
|
||||
<Link
|
||||
className={className}
|
||||
component={TableRowCell}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TableRowCellButton.propTypes = {
|
||||
className: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
TableRowCellButton.defaultProps = {
|
||||
className: styles.cell
|
||||
};
|
||||
|
||||
export default TableRowCellButton;
|
||||
19
frontend/src/Components/Table/Cells/TableRowCellButton.tsx
Normal file
19
frontend/src/Components/Table/Cells/TableRowCellButton.tsx
Normal file
@@ -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 (
|
||||
<Link className={className} component={TableRowCell} {...otherProps} />
|
||||
);
|
||||
}
|
||||
|
||||
export default TableRowCellButton;
|
||||
8
frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
vendored
Normal file
8
frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'input': string;
|
||||
'selectCell': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Column {
|
||||
name: string;
|
||||
label: string;
|
||||
columnLabel: string;
|
||||
isSortable: boolean;
|
||||
label: string | React.ReactNode;
|
||||
columnLabel?: string;
|
||||
isSortable?: boolean;
|
||||
isVisible: boolean;
|
||||
isModifiable?: boolean;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ function Table(props) {
|
||||
}
|
||||
|
||||
Table.propTypes = {
|
||||
...TableHeaderCell.props,
|
||||
className: PropTypes.string,
|
||||
horizontalScroll: PropTypes.bool.isRequired,
|
||||
selectAll: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -39,7 +39,8 @@ class VirtualTable extends Component {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
width: 0
|
||||
width: 0,
|
||||
scrollRestored: false
|
||||
};
|
||||
|
||||
this._grid = null;
|
||||
@@ -48,20 +49,25 @@ class VirtualTable extends Component {
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
items,
|
||||
scrollIndex
|
||||
scrollIndex,
|
||||
scrollTop
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
width
|
||||
width,
|
||||
scrollRestored
|
||||
} = this.state;
|
||||
|
||||
if (this._grid &&
|
||||
(prevState.width !== width ||
|
||||
hasDifferentItemsOrOrder(prevProps.items, items))) {
|
||||
if (this._grid && (prevState.width !== width || hasDifferentItemsOrOrder(prevProps.items, items))) {
|
||||
// recomputeGridSize also forces Grid to discard its cache of rendered cells
|
||||
this._grid.recomputeGridSize();
|
||||
}
|
||||
|
||||
if (this._grid && scrollTop !== undefined && scrollTop !== 0 && !scrollRestored) {
|
||||
this.setState({ scrollRestored: true });
|
||||
this._grid.scrollToPosition({ scrollTop });
|
||||
}
|
||||
|
||||
if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
|
||||
this._grid.scrollToCell({
|
||||
rowIndex: scrollIndex,
|
||||
@@ -98,6 +104,7 @@ class VirtualTable extends Component {
|
||||
focusScroller,
|
||||
header,
|
||||
headerHeight,
|
||||
rowHeight,
|
||||
rowRenderer,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
@@ -141,6 +148,7 @@ class VirtualTable extends Component {
|
||||
{header}
|
||||
<div ref={registerChild}>
|
||||
<Grid
|
||||
{...otherProps}
|
||||
ref={this.setGridRef}
|
||||
autoContainerWidth={true}
|
||||
autoHeight={true}
|
||||
@@ -148,7 +156,7 @@ class VirtualTable extends Component {
|
||||
width={width}
|
||||
height={height}
|
||||
headerHeight={height - headerHeight}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={items.length}
|
||||
columnCount={1}
|
||||
columnWidth={width}
|
||||
@@ -162,7 +170,6 @@ class VirtualTable extends Component {
|
||||
className={styles.tableBodyContainer}
|
||||
style={gridStyle}
|
||||
containerStyle={containerStyle}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
</Scroller>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -67,8 +67,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 = () => {
|
||||
|
||||
@@ -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<WrappedComponentProps>,
|
||||
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 <WrappedComponent {...props} initialScrollTop={initialScrollTop} />;
|
||||
}
|
||||
|
||||
ScrollPosition.propTypes = {
|
||||
history: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
return ScrollPosition;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
11
frontend/src/Helpers/Hooks/usePrevious.tsx
Normal file
11
frontend/src/Helpers/Hooks/usePrevious.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
113
frontend/src/Helpers/Hooks/useSelectState.tsx
Normal file
113
frontend/src/Helpers/Hooks/useSelectState.tsx
Normal file
@@ -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<number, boolean>;
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -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' }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
faLanguage as fasLanguage,
|
||||
faLaptop as fasLaptop,
|
||||
faLevelUpAlt as fasLevelUpAlt,
|
||||
faListCheck as fasListCheck,
|
||||
faLocationArrow as fasLocationArrow,
|
||||
faLock as fasLock,
|
||||
faMedkit as fasMedkit,
|
||||
@@ -180,6 +181,7 @@ export const INTERACTIVE = fasUser;
|
||||
export const KEYBOARD = farKeyboard;
|
||||
export const LOCK = fasLock;
|
||||
export const LOGOUT = fasSignOutAlt;
|
||||
export const MANAGE = fasListCheck;
|
||||
export const MEDIA_INFO = farFileInvoice;
|
||||
export const MISSING = fasExclamationTriangle;
|
||||
export const MONITORED = fasBookmark;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 NUMBER = 'number';
|
||||
export const OAUTH = 'oauth';
|
||||
export const PASSWORD = 'password';
|
||||
|
||||
@@ -18,6 +18,8 @@ function HistoryDetails(props) {
|
||||
query,
|
||||
queryResults,
|
||||
categories,
|
||||
limit,
|
||||
offset,
|
||||
source,
|
||||
url
|
||||
} = data;
|
||||
@@ -31,43 +33,66 @@ function HistoryDetails(props) {
|
||||
/>
|
||||
|
||||
{
|
||||
!!indexer &&
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer.name}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('QueryResults')}
|
||||
data={queryResults ? queryResults : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Categories')}
|
||||
data={categories ? categories : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
limit ?
|
||||
<DescriptionListItem
|
||||
title={translate('Limit')}
|
||||
data={limit}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
offset ?
|
||||
<DescriptionListItem
|
||||
title={translate('Offset')}
|
||||
data={offset}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Source')}
|
||||
data={source}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Url')}
|
||||
data={url ? <Link to={url}>{translate('Link')}</Link> : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
@@ -76,42 +101,46 @@ function HistoryDetails(props) {
|
||||
if (eventType === 'releaseGrabbed') {
|
||||
const {
|
||||
source,
|
||||
title,
|
||||
grabTitle,
|
||||
url
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
{
|
||||
!!indexer &&
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer.name}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Source')}
|
||||
data={source ? source : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Title')}
|
||||
data={title ? title : '-'}
|
||||
/>
|
||||
title={translate('GrabTitle')}
|
||||
data={grabTitle ? grabTitle : '-'}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Url')}
|
||||
data={url ? <Link to={url}>{translate('Link')}</Link> : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
@@ -124,11 +153,12 @@ function HistoryDetails(props) {
|
||||
title={translate('Auth')}
|
||||
>
|
||||
{
|
||||
!!indexer &&
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer.name}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
|
||||
@@ -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 FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
@@ -121,9 +122,9 @@ class History extends Component {
|
||||
|
||||
{
|
||||
!isFetchingAny && hasError &&
|
||||
<div>
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('UnableToLoadHistory')}
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
@@ -131,9 +132,9 @@ class History extends Component {
|
||||
// wait for the episodes to populate because they are never coming.
|
||||
|
||||
isPopulated && !hasError && !items.length &&
|
||||
<div>
|
||||
No history found
|
||||
</div>
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('NoHistoryFound')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -62,6 +62,7 @@ class HistoryOptions extends Component {
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="historyCleanupDays"
|
||||
unit={translate('days')}
|
||||
value={historyCleanupDays}
|
||||
helpText={translate('HistoryCleanupDaysHelpText')}
|
||||
helpTextWarning={translate('HistoryCleanupDaysHelpTextWarning')}
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.parameters {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
.parametersContent {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
2
frontend/src/History/HistoryRow.css.d.ts
vendored
2
frontend/src/History/HistoryRow.css.d.ts
vendored
@@ -6,7 +6,7 @@ interface CssExports {
|
||||
'details': string;
|
||||
'elapsedTime': string;
|
||||
'indexer': string;
|
||||
'parameters': string;
|
||||
'parametersContent': string;
|
||||
'query': string;
|
||||
'releaseGroup': string;
|
||||
'source': string;
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryDetailsModal from './Details/HistoryDetailsModal';
|
||||
import * as historyDataTypes from './historyDataTypes';
|
||||
import HistoryEventTypeCell from './HistoryEventTypeCell';
|
||||
import HistoryRowParameter from './HistoryRowParameter';
|
||||
import styles from './HistoryRow.css';
|
||||
|
||||
const historyParameters = [
|
||||
{ key: historyDataTypes.IMDB_ID, title: 'IMDb' },
|
||||
{ key: historyDataTypes.TMDB_ID, title: 'TMDb' },
|
||||
{ key: historyDataTypes.TVDB_ID, title: 'TVDb' },
|
||||
{ key: historyDataTypes.TRAKT_ID, title: 'Trakt' },
|
||||
{ key: historyDataTypes.R_ID, title: 'TvRage' },
|
||||
{ key: historyDataTypes.TVMAZE_ID, title: 'TvMaze' },
|
||||
{ key: historyDataTypes.SEASON, title: translate('Season') },
|
||||
{ key: historyDataTypes.EPISODE, title: translate('Episode') },
|
||||
{ key: historyDataTypes.ARTIST, title: translate('Artist') },
|
||||
{ key: historyDataTypes.ALBUM, title: translate('Album') },
|
||||
{ key: historyDataTypes.LABEL, title: translate('Label') },
|
||||
{ key: historyDataTypes.TRACK, title: translate('Track') },
|
||||
{ key: historyDataTypes.YEAR, title: translate('Year') },
|
||||
{ key: historyDataTypes.GENRE, title: translate('Genre') },
|
||||
{ key: historyDataTypes.AUTHOR, title: translate('Author') },
|
||||
{ key: historyDataTypes.TITLE, title: translate('Title') },
|
||||
{ key: historyDataTypes.PUBLISHER, title: translate('Publisher') }
|
||||
];
|
||||
|
||||
class HistoryRow extends Component {
|
||||
|
||||
//
|
||||
@@ -44,15 +66,52 @@ class HistoryRow extends Component {
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
const { query, queryType, limit, offset } = data;
|
||||
|
||||
let searchQuery = query;
|
||||
let categories = [];
|
||||
|
||||
if (data.categories) {
|
||||
categories = data.categories.split(',').map((item) => {
|
||||
return parseInt(item);
|
||||
});
|
||||
categories = data.categories.split(',').map((item) => parseInt(item));
|
||||
}
|
||||
|
||||
this.props.onSearchPress(data.query, indexer.id, categories);
|
||||
const searchParams = [
|
||||
historyDataTypes.IMDB_ID,
|
||||
historyDataTypes.TMDB_ID,
|
||||
historyDataTypes.TVDB_ID,
|
||||
historyDataTypes.TRAKT_ID,
|
||||
historyDataTypes.R_ID,
|
||||
historyDataTypes.TVMAZE_ID,
|
||||
historyDataTypes.SEASON,
|
||||
historyDataTypes.EPISODE,
|
||||
historyDataTypes.ARTIST,
|
||||
historyDataTypes.ALBUM,
|
||||
historyDataTypes.LABEL,
|
||||
historyDataTypes.TRACK,
|
||||
historyDataTypes.YEAR,
|
||||
historyDataTypes.GENRE,
|
||||
historyDataTypes.AUTHOR,
|
||||
historyDataTypes.TITLE,
|
||||
historyDataTypes.PUBLISHER
|
||||
]
|
||||
.reduce((acc, key) => {
|
||||
if (key in data && data[key].length > 0) {
|
||||
const value = data[key];
|
||||
|
||||
acc.push({ key, value });
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item) => `{${item.key}:${item.value}}`)
|
||||
.join('')
|
||||
;
|
||||
|
||||
if (searchParams.length > 0) {
|
||||
searchQuery += `${searchParams}`;
|
||||
}
|
||||
|
||||
this.props.onSearchPress(searchQuery, indexer.id, categories, queryType, parseInt(limit), parseInt(offset));
|
||||
};
|
||||
|
||||
onDetailsPress = () => {
|
||||
@@ -84,6 +143,8 @@ class HistoryRow extends Component {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parameters = historyParameters.filter((parameter) => parameter.key in data && data[parameter.key]);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{
|
||||
@@ -133,162 +194,19 @@ class HistoryRow extends Component {
|
||||
|
||||
if (name === 'parameters') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.parameters}
|
||||
>
|
||||
{
|
||||
data.imdbId ?
|
||||
<HistoryRowParameter
|
||||
title='IMDb'
|
||||
value={data.imdbId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.tmdbId ?
|
||||
<HistoryRowParameter
|
||||
title='TMDb'
|
||||
value={data.tmdbId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.tvdbId ?
|
||||
<HistoryRowParameter
|
||||
title='TVDb'
|
||||
value={data.tvdbId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.traktId ?
|
||||
<HistoryRowParameter
|
||||
title='Trakt'
|
||||
value={data.traktId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.rId ?
|
||||
<HistoryRowParameter
|
||||
title='TvRage'
|
||||
value={data.rId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.tvMazeId ?
|
||||
<HistoryRowParameter
|
||||
title='TvMaze'
|
||||
value={data.tvMazeId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.season ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Season')}
|
||||
value={data.season}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.episode ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Episode')}
|
||||
value={data.episode}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.artist ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Artist')}
|
||||
value={data.artist}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.album ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Album')}
|
||||
value={data.album}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.label ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Label')}
|
||||
value={data.label}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.track ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Track')}
|
||||
value={data.track}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.year ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Year')}
|
||||
value={data.year}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.genre ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Genre')}
|
||||
value={data.genre}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.author ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Author')}
|
||||
value={data.author}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.bookTitle ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Book')}
|
||||
value={data.bookTitle}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.publisher ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Publisher')}
|
||||
value={data.publisher}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
<TableRowCell key={name}>
|
||||
<div className={styles.parametersContent}>
|
||||
{parameters.map((parameter) => {
|
||||
return (
|
||||
<HistoryRowParameter
|
||||
key={parameter.key}
|
||||
title={parameter.title}
|
||||
value={data[parameter.key]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
@@ -300,8 +218,25 @@ class HistoryRow extends Component {
|
||||
className={styles.indexer}
|
||||
>
|
||||
{
|
||||
data.title ?
|
||||
data.title :
|
||||
data.grabTitle ?
|
||||
data.grabTitle :
|
||||
null
|
||||
}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'queryType') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.query}
|
||||
>
|
||||
{
|
||||
data.queryType ?
|
||||
<Label kind={kinds.INFO}>
|
||||
{data.queryType}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</TableRowCell>
|
||||
@@ -377,6 +312,12 @@ class HistoryRow extends Component {
|
||||
key={name}
|
||||
className={styles.details}
|
||||
>
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
onPress={this.onDetailsPress}
|
||||
title={translate('HistoryDetails')}
|
||||
/>
|
||||
|
||||
{
|
||||
eventType === 'indexerQuery' ?
|
||||
<IconButton
|
||||
@@ -386,11 +327,6 @@ class HistoryRow extends Component {
|
||||
/> :
|
||||
null
|
||||
}
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
onPress={this.onDetailsPress}
|
||||
title={translate('HistoryDetails')}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -48,8 +49,15 @@ class HistoryRowConnector extends Component {
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSearchPress = (term, indexerId, categories) => {
|
||||
this.props.setSearchDefault({ searchQuery: term, searchIndexerIds: [indexerId], searchCategories: categories });
|
||||
onSearchPress = (query, indexerId, categories, type, limit, offset) => {
|
||||
this.props.setSearchDefault(_.pickBy({
|
||||
searchQuery: query,
|
||||
searchIndexerIds: [indexerId],
|
||||
searchCategories: categories,
|
||||
searchType: type,
|
||||
searchLimit: limit,
|
||||
searchOffset: offset
|
||||
}));
|
||||
this.props.push(`${window.Prowlarr.urlBase}/search`);
|
||||
};
|
||||
|
||||
|
||||
17
frontend/src/History/historyDataTypes.js
Normal file
17
frontend/src/History/historyDataTypes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export const IMDB_ID = 'imdbId';
|
||||
export const TMDB_ID = 'tmdbId';
|
||||
export const TVDB_ID = 'tvdbId';
|
||||
export const TRAKT_ID = 'traktId';
|
||||
export const R_ID = 'rId';
|
||||
export const TVMAZE_ID = 'tvMazeId';
|
||||
export const SEASON = 'season';
|
||||
export const EPISODE = 'episode';
|
||||
export const ARTIST = 'artist';
|
||||
export const ALBUM = 'album';
|
||||
export const LABEL = 'label';
|
||||
export const TRACK = 'track';
|
||||
export const YEAR = 'year';
|
||||
export const GENRE = 'genre';
|
||||
export const AUTHOR = 'author';
|
||||
export const TITLE = 'title';
|
||||
export const PUBLISHER = 'publisher';
|
||||
@@ -52,17 +52,22 @@
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.filterInput {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
flex-direction: column;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
margin-right: 0;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
@@ -71,3 +76,30 @@
|
||||
margin-left: -30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: $breakpointSmall) {
|
||||
.filterContainer {
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
composes: modalFooter from '~Components/Modal/ModalFooter.css';
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.available {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointExtraSmall) {
|
||||
.modalFooter {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.available {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'alert': string;
|
||||
'available': string;
|
||||
'filterContainer': string;
|
||||
'filterInput': string;
|
||||
'filterLabel': string;
|
||||
'filterRow': string;
|
||||
'indexers': string;
|
||||
'modalBody': string;
|
||||
'modalFooter': string;
|
||||
'scroller': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput';
|
||||
import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -26,7 +27,7 @@ const columns = [
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
name: 'sortName',
|
||||
label: translate('Name'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
@@ -89,7 +90,8 @@ class AddIndexerModalContent extends Component {
|
||||
filter: '',
|
||||
filterProtocols: [],
|
||||
filterLanguages: [],
|
||||
filterPrivacyLevels: []
|
||||
filterPrivacyLevels: [],
|
||||
filterCategories: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +123,13 @@ class AddIndexerModalContent extends Component {
|
||||
.map((language) => ({ key: language, value: language }));
|
||||
|
||||
const filteredIndexers = indexers.filter((indexer) => {
|
||||
const { filter, filterProtocols, filterLanguages, filterPrivacyLevels } = this.state;
|
||||
const {
|
||||
filter,
|
||||
filterProtocols,
|
||||
filterLanguages,
|
||||
filterPrivacyLevels,
|
||||
filterCategories
|
||||
} = this.state;
|
||||
|
||||
if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())) {
|
||||
return false;
|
||||
@@ -139,6 +147,18 @@ class AddIndexerModalContent extends Component {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCategories.length) {
|
||||
const { categories = [] } = indexer.capabilities || {};
|
||||
const flat = ({ id, subCategories = [] }) => [id, ...subCategories.flatMap(flat)];
|
||||
const flatCategories = categories
|
||||
.filter((item) => item.id < 100000)
|
||||
.flatMap(flat);
|
||||
|
||||
if (!filterCategories.every((item) => flatCategories.includes(item))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -165,7 +185,7 @@ class AddIndexerModalContent extends Component {
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>Protocol</label>
|
||||
<label className={styles.filterLabel}>{translate('Protocol')}</label>
|
||||
<EnhancedSelectInput
|
||||
name="indexerProtocols"
|
||||
value={this.state.filterProtocols}
|
||||
@@ -175,7 +195,7 @@ class AddIndexerModalContent extends Component {
|
||||
</div>
|
||||
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>Language</label>
|
||||
<label className={styles.filterLabel}>{translate('Language')}</label>
|
||||
<EnhancedSelectInput
|
||||
name="indexerLanguages"
|
||||
value={this.state.filterLanguages}
|
||||
@@ -185,7 +205,7 @@ class AddIndexerModalContent extends Component {
|
||||
</div>
|
||||
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>Privacy</label>
|
||||
<label className={styles.filterLabel}>{translate('Privacy')}</label>
|
||||
<EnhancedSelectInput
|
||||
name="indexerPrivacyLevels"
|
||||
value={this.state.filterPrivacyLevels}
|
||||
@@ -193,6 +213,15 @@ class AddIndexerModalContent extends Component {
|
||||
onChange={({ value }) => this.setState({ filterPrivacyLevels: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>{translate('Categories')}</label>
|
||||
<NewznabCategorySelectInputConnector
|
||||
name="indexerCategories"
|
||||
value={this.state.filterCategories}
|
||||
onChange={({ value }) => this.setState({ filterCategories: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
@@ -212,7 +241,7 @@ class AddIndexerModalContent extends Component {
|
||||
isFetching ? <LoadingIndicator /> : null
|
||||
}
|
||||
{
|
||||
error ? <div>{errorMessage}</div> : null
|
||||
error ? <Alert kind={kinds.DANGER}>{errorMessage}</Alert> : null
|
||||
}
|
||||
{
|
||||
isPopulated && !!indexers.length ?
|
||||
@@ -226,7 +255,7 @@ class AddIndexerModalContent extends Component {
|
||||
{
|
||||
filteredIndexers.map((indexer) => (
|
||||
<SelectIndexerRowConnector
|
||||
key={indexer.name}
|
||||
key={`${indexer.implementation}-${indexer.name}`}
|
||||
implementation={indexer.implementation}
|
||||
{...indexer}
|
||||
onIndexerSelect={onIndexerSelect}
|
||||
@@ -237,15 +266,30 @@ class AddIndexerModalContent extends Component {
|
||||
</Table> :
|
||||
null
|
||||
}
|
||||
{
|
||||
isPopulated && !!indexers.length && !filteredIndexers.length ?
|
||||
<Alert
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{translate('NoIndexersFound')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div className={styles.available}>
|
||||
{
|
||||
isPopulated ?
|
||||
translate('CountIndexersAvailable', [filteredIndexers.length]) :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import DeleteIndexerModalContentConnector from './DeleteIndexerModalContentConnector';
|
||||
|
||||
function DeleteIndexerModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={sizes.MEDIUM}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<DeleteIndexerModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteIndexerModal.propTypes = {
|
||||
...DeleteIndexerModalContentConnector.propTypes,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DeleteIndexerModal;
|
||||
25
frontend/src/Indexer/Delete/DeleteIndexerModal.tsx
Normal file
25
frontend/src/Indexer/Delete/DeleteIndexerModal.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import DeleteIndexerModalContent from './DeleteIndexerModalContent';
|
||||
|
||||
interface DeleteIndexerModalProps {
|
||||
isOpen: boolean;
|
||||
indexerId: number;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function DeleteIndexerModal(props: DeleteIndexerModalProps) {
|
||||
const { isOpen, indexerId, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
|
||||
<DeleteIndexerModalContent
|
||||
indexerId={indexerId}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteIndexerModal;
|
||||
@@ -1,88 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
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 { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
class DeleteIndexerModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
deleteFiles: false,
|
||||
addImportExclusion: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onDeleteFilesChange = ({ value }) => {
|
||||
this.setState({ deleteFiles: value });
|
||||
};
|
||||
|
||||
onAddImportExclusionChange = ({ value }) => {
|
||||
this.setState({ addImportExclusion: value });
|
||||
};
|
||||
|
||||
onDeleteMovieConfirmed = () => {
|
||||
const deleteFiles = this.state.deleteFiles;
|
||||
const addImportExclusion = this.state.addImportExclusion;
|
||||
|
||||
this.setState({ deleteFiles: false, addImportExclusion: false });
|
||||
this.props.onDeletePress(deleteFiles, addImportExclusion);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
Delete - {name}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{`Are you sure you want to delete ${name} from Prowlarr`}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.DANGER}
|
||||
onPress={this.onDeleteMovieConfirmed}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DeleteIndexerModalContent.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
onDeletePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DeleteIndexerModalContent;
|
||||
51
frontend/src/Indexer/Delete/DeleteIndexerModalContent.tsx
Normal file
51
frontend/src/Indexer/Delete/DeleteIndexerModalContent.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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 { kinds } from 'Helpers/Props';
|
||||
import { deleteIndexer } from 'Store/Actions/indexerActions';
|
||||
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface DeleteIndexerModalContentProps {
|
||||
indexerId: number;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
const { indexerId, onModalClose } = props;
|
||||
|
||||
const { name } = useSelector(createIndexerSelectorForHook(indexerId));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
dispatch(deleteIndexer({ id: indexerId }));
|
||||
|
||||
onModalClose();
|
||||
}, [indexerId, dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('Delete')} - {name}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{translate('AreYouSureYouWantToDeleteIndexer', [name])}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
|
||||
<Button kind={kinds.DANGER} onPress={onConfirmDelete}>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteIndexerModalContent;
|
||||
@@ -1,57 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteIndexer } from 'Store/Actions/indexerActions';
|
||||
import createIndexerSelector from 'Store/Selectors/createIndexerSelector';
|
||||
import DeleteIndexerModalContent from './DeleteIndexerModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createIndexerSelector(),
|
||||
(indexer) => {
|
||||
return indexer;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
deleteIndexer,
|
||||
push
|
||||
};
|
||||
|
||||
class DeleteIndexerModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onDeletePress = () => {
|
||||
this.props.deleteIndexer({
|
||||
id: this.props.indexerId
|
||||
});
|
||||
|
||||
this.props.onModalClose(true);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DeleteIndexerModalContent
|
||||
{...this.props}
|
||||
onDeletePress={this.onDeletePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DeleteIndexerModalContentConnector.propTypes = {
|
||||
indexerId: PropTypes.number.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
deleteIndexer: PropTypes.func.isRequired,
|
||||
push: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(DeleteIndexerModalContentConnector);
|
||||
@@ -26,6 +26,8 @@ function EditIndexerModalContent(props) {
|
||||
isTesting,
|
||||
saveError,
|
||||
item,
|
||||
hasUsenetDownloadClients,
|
||||
hasTorrentDownloadClients,
|
||||
onInputChange,
|
||||
onFieldChange,
|
||||
onModalClose,
|
||||
@@ -48,10 +50,13 @@ function EditIndexerModalContent(props) {
|
||||
appProfileId,
|
||||
tags,
|
||||
fields,
|
||||
priority
|
||||
priority,
|
||||
protocol,
|
||||
downloadClientId
|
||||
} = item;
|
||||
|
||||
const indexerDisplayName = implementationName === definitionName ? implementationName : `${implementationName} (${definitionName})`;
|
||||
const showDownloadClientInput = downloadClientId.value > 0 || protocol.value === 'usenet' && hasUsenetDownloadClients || protocol.value === 'torrent' && hasTorrentDownloadClients;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -156,6 +161,25 @@ function EditIndexerModalContent(props) {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{showDownloadClientInput ?
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('DownloadClient')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.DOWNLOAD_CLIENT_SELECT}
|
||||
name="downloadClientId"
|
||||
helpText={translate('IndexerDownloadClientHelpText')}
|
||||
{...downloadClientId}
|
||||
includeAny={true}
|
||||
protocol={protocol.value}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup> : null
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
@@ -222,6 +246,8 @@ EditIndexerModalContent.propTypes = {
|
||||
isTesting: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
hasUsenetDownloadClients: PropTypes.bool.isRequired,
|
||||
hasTorrentDownloadClients: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onFieldChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
|
||||
@@ -3,17 +3,23 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveIndexer, setIndexerFieldValue, setIndexerValue, testIndexer } from 'Store/Actions/indexerActions';
|
||||
import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
|
||||
import { fetchDownloadClients, toggleAdvancedSettings } from 'Store/Actions/settingsActions';
|
||||
import createIndexerSchemaSelector from 'Store/Selectors/createIndexerSchemaSelector';
|
||||
import EditIndexerModalContent from './EditIndexerModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.advancedSettings,
|
||||
(state) => state.settings.downloadClients,
|
||||
createIndexerSchemaSelector(),
|
||||
(advancedSettings, indexer) => {
|
||||
(advancedSettings, downloadClients, indexer) => {
|
||||
const usenetDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'usenet');
|
||||
const torrentDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'torrent');
|
||||
|
||||
return {
|
||||
advancedSettings,
|
||||
hasUsenetDownloadClients: usenetDownloadClients.length > 0,
|
||||
hasTorrentDownloadClients: torrentDownloadClients.length > 0,
|
||||
...indexer
|
||||
};
|
||||
}
|
||||
@@ -25,7 +31,8 @@ const mapDispatchToProps = {
|
||||
setIndexerFieldValue,
|
||||
saveIndexer,
|
||||
testIndexer,
|
||||
toggleAdvancedSettings
|
||||
toggleAdvancedSettings,
|
||||
dispatchFetchDownloadClients: fetchDownloadClients
|
||||
};
|
||||
|
||||
class EditIndexerModalContentConnector extends Component {
|
||||
@@ -33,6 +40,10 @@ class EditIndexerModalContentConnector extends Component {
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchDownloadClients();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
@@ -90,7 +101,8 @@ EditIndexerModalContentConnector.propTypes = {
|
||||
toggleAdvancedSettings: PropTypes.func.isRequired,
|
||||
saveIndexer: PropTypes.func.isRequired,
|
||||
testIndexer: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
dispatchFetchDownloadClients: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
|
||||
import IndexerAppState, {
|
||||
IndexerIndexAppState,
|
||||
} from 'App/State/IndexerAppState';
|
||||
import { APP_INDEXER_SYNC } from 'Commands/commandNames';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
@@ -64,19 +68,20 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
sortKey,
|
||||
sortDirection,
|
||||
view,
|
||||
} = useSelector(
|
||||
createIndexerClientSideCollectionItemsSelector('indexerIndex')
|
||||
);
|
||||
}: IndexerAppState & IndexerIndexAppState & ClientSideCollectionAppState =
|
||||
useSelector(createIndexerClientSideCollectionItemsSelector('indexerIndex'));
|
||||
|
||||
const isSyncingIndexers = useSelector(
|
||||
createCommandExecutingSelector(APP_INDEXER_SYNC)
|
||||
);
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const dispatch = useDispatch();
|
||||
const scrollerRef = useRef<HTMLDivElement>();
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAddIndexerModalOpen, setIsAddIndexerModalOpen] = useState(false);
|
||||
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
|
||||
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
|
||||
const [jumpToCharacter, setJumpToCharacter] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
|
||||
const onAppIndexerSyncPress = useCallback(() => {
|
||||
@@ -112,53 +117,53 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
}, [isSelectMode, setIsSelectMode]);
|
||||
|
||||
const onTableOptionChange = useCallback(
|
||||
(payload) => {
|
||||
(payload: unknown) => {
|
||||
dispatch(setIndexerTableOption(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onSortSelect = useCallback(
|
||||
(value) => {
|
||||
(value: string) => {
|
||||
dispatch(setIndexerSort({ sortKey: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onFilterSelect = useCallback(
|
||||
(value) => {
|
||||
(value: string) => {
|
||||
dispatch(setIndexerFilter({ selectedFilterKey: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onJumpBarItemPress = useCallback(
|
||||
(character) => {
|
||||
(character: string) => {
|
||||
setJumpToCharacter(character);
|
||||
},
|
||||
[setJumpToCharacter]
|
||||
);
|
||||
|
||||
const onScroll = useCallback(
|
||||
({ scrollTop }) => {
|
||||
setJumpToCharacter(null);
|
||||
scrollPositions.seriesIndex = scrollTop;
|
||||
({ scrollTop }: { scrollTop: number }) => {
|
||||
setJumpToCharacter(undefined);
|
||||
scrollPositions.indexerIndex = scrollTop;
|
||||
},
|
||||
[setJumpToCharacter]
|
||||
);
|
||||
|
||||
const jumpBarItems = useMemo(() => {
|
||||
// Reset if not sorting by sortTitle
|
||||
if (sortKey !== 'sortTitle') {
|
||||
// Reset if not sorting by sortName
|
||||
if (sortKey !== 'sortName') {
|
||||
return {
|
||||
order: [],
|
||||
};
|
||||
}
|
||||
|
||||
const characters = items.reduce((acc, item) => {
|
||||
let char = item.sortTitle.charAt(0);
|
||||
const characters = items.reduce((acc: Record<string, number>, item) => {
|
||||
let char = item.sortName.charAt(0);
|
||||
|
||||
if (!isNaN(char)) {
|
||||
if (!isNaN(Number(char))) {
|
||||
char = '#';
|
||||
}
|
||||
|
||||
@@ -190,7 +195,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<PageContent>
|
||||
<PageContent title={translate('Indexers')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
@@ -225,7 +230,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
label={
|
||||
isSelectMode
|
||||
? translate('StopSelecting')
|
||||
: translate('SelectIndexer')
|
||||
: translate('SelectIndexers')
|
||||
}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
isSelectMode={isSelectMode}
|
||||
@@ -277,6 +282,8 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
<PageContentBody
|
||||
ref={scrollerRef}
|
||||
className={styles.contentBody}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
innerClassName={styles[`${view}InnerContentBody`]}
|
||||
initialScrollTop={props.initialScrollTop}
|
||||
onScroll={onScroll}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 { setIndexerFilter } from 'Store/Actions/indexerIndexActions';
|
||||
|
||||
function createIndexerSelector() {
|
||||
return createSelector(
|
||||
(state) => state.indexers.items,
|
||||
(state: AppState) => state.indexers.items,
|
||||
(indexers) => {
|
||||
return indexers;
|
||||
}
|
||||
@@ -15,14 +16,20 @@ function createIndexerSelector() {
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state) => state.indexerIndex.filterBuilderProps,
|
||||
(state: AppState) => state.indexerIndex.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function IndexerIndexFilterModal(props) {
|
||||
interface IndexerIndexFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function IndexerIndexFilterModal(
|
||||
props: IndexerIndexFilterModalProps
|
||||
) {
|
||||
const sectionItems = useSelector(createIndexerSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'indexerIndex';
|
||||
@@ -30,7 +37,7 @@ export default function IndexerIndexFilterModal(props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload) => {
|
||||
(payload: unknown) => {
|
||||
dispatch(setIndexerFilter(payload));
|
||||
},
|
||||
[dispatch]
|
||||
@@ -38,6 +45,7 @@ export default function IndexerIndexFilterModal(props) {
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
|
||||
import IndexerAppState from 'App/State/IndexerAppState';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
@@ -13,7 +14,7 @@ import styles from './IndexerIndexFooter.css';
|
||||
function createUnoptimizedSelector() {
|
||||
return createSelector(
|
||||
createClientSideCollectionSelector('indexers', 'indexerIndex'),
|
||||
(indexers) => {
|
||||
(indexers: IndexerAppState) => {
|
||||
return indexers.items.map((s) => {
|
||||
const { protocol, privacy, enable } = s;
|
||||
|
||||
|
||||
@@ -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 IndexerIndexFilterModal from 'Indexer/Index/IndexerIndexFilterModal';
|
||||
|
||||
function IndexerIndexFilterMenu(props) {
|
||||
interface IndexerIndexFilterMenuProps {
|
||||
selectedFilterKey: string | number;
|
||||
filters: object[];
|
||||
customFilters: CustomFilter[];
|
||||
isDisabled: boolean;
|
||||
onFilterSelect(filterName: string): unknown;
|
||||
}
|
||||
|
||||
function IndexerIndexFilterMenu(props: IndexerIndexFilterMenuProps) {
|
||||
const {
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
@@ -26,15 +34,6 @@ function IndexerIndexFilterMenu(props) {
|
||||
);
|
||||
}
|
||||
|
||||
IndexerIndexFilterMenu.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,
|
||||
};
|
||||
|
||||
IndexerIndexFilterMenu.defaultProps = {
|
||||
showCustomFilters: false,
|
||||
};
|
||||
|
||||
@@ -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 IndexerIndexSortMenu(props) {
|
||||
interface IndexerIndexSortMenuProps {
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
isDisabled: boolean;
|
||||
onSortSelect(sortKey: string): unknown;
|
||||
}
|
||||
|
||||
function IndexerIndexSortMenu(props: IndexerIndexSortMenuProps) {
|
||||
const { sortKey, sortDirection, isDisabled, onSortSelect } = props;
|
||||
|
||||
return (
|
||||
@@ -79,11 +86,4 @@ function IndexerIndexSortMenu(props) {
|
||||
);
|
||||
}
|
||||
|
||||
IndexerIndexSortMenu.propTypes = {
|
||||
sortKey: PropTypes.string,
|
||||
sortDirection: PropTypes.oneOf(sortDirections.all),
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
onSortSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default IndexerIndexSortMenu;
|
||||
|
||||
@@ -7,8 +7,10 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { bulkDeleteIndexers } from 'Store/Actions/indexerIndexActions';
|
||||
import Indexer from 'Indexer/Indexer';
|
||||
import { bulkDeleteIndexers } from 'Store/Actions/indexerActions';
|
||||
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './DeleteIndexerModalContent.css';
|
||||
|
||||
interface DeleteIndexerModalContentProps {
|
||||
@@ -19,21 +21,21 @@ interface DeleteIndexerModalContentProps {
|
||||
function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
const { indexerIds, onModalClose } = props;
|
||||
|
||||
const allIndexer = useSelector(createAllIndexersSelector());
|
||||
const allIndexers: Indexer[] = useSelector(createAllIndexersSelector());
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const indexers = useMemo(() => {
|
||||
const indexers = indexerIds.map((id) => {
|
||||
return allIndexer.find((s) => s.id === id);
|
||||
});
|
||||
const indexers = useMemo((): Indexer[] => {
|
||||
const indexerList = indexerIds.map((id) => {
|
||||
return allIndexers.find((s) => s.id === id);
|
||||
}) as Indexer[];
|
||||
|
||||
return orderBy(indexers, ['sortTitle']);
|
||||
}, [indexerIds, allIndexer]);
|
||||
return orderBy(indexerList, ['sortName']);
|
||||
}, [indexerIds, allIndexers]);
|
||||
|
||||
const onDeleteIndexerConfirmed = useCallback(() => {
|
||||
dispatch(
|
||||
bulkDeleteIndexers({
|
||||
indexerIds,
|
||||
ids: indexerIds,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -42,17 +44,17 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>Delete Selected Indexer</ModalHeader>
|
||||
<ModalHeader>{translate('DeleteSelectedIndexers')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.message}>
|
||||
{`Are you sure you want to delete ${indexers.length} selected indexers?`}
|
||||
{translate('DeleteSelectedIndexersMessageText', [indexers.length])}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{indexers.map((s) => {
|
||||
return (
|
||||
<li key={s.name}>
|
||||
<li key={s.id}>
|
||||
<span>{s.name}</span>
|
||||
</li>
|
||||
);
|
||||
@@ -61,10 +63,10 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.DANGER} onPress={onDeleteIndexerConfirmed}>
|
||||
Delete
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -7,13 +7,18 @@ 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 { inputTypes, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditIndexerModalContent.css';
|
||||
|
||||
interface SavePayload {
|
||||
enable?: boolean;
|
||||
appProfileId?: number;
|
||||
priority?: number;
|
||||
minimumSeeders?: number;
|
||||
seedRatio?: number;
|
||||
seedTime?: number;
|
||||
packSeedTime?: number;
|
||||
}
|
||||
|
||||
interface EditIndexerModalContentProps {
|
||||
@@ -35,6 +40,15 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
|
||||
const [enable, setEnable] = useState(NO_CHANGE);
|
||||
const [appProfileId, setAppProfileId] = useState<string | number>(NO_CHANGE);
|
||||
const [priority, setPriority] = useState<null | string | number>(null);
|
||||
const [minimumSeeders, setMinimumSeeders] = useState<null | string | number>(
|
||||
null
|
||||
);
|
||||
const [seedRatio, setSeedRatio] = useState<null | string | number>(null);
|
||||
const [seedTime, setSeedTime] = useState<null | string | number>(null);
|
||||
const [packSeedTime, setPackSeedTime] = useState<null | string | number>(
|
||||
null
|
||||
);
|
||||
|
||||
const save = useCallback(() => {
|
||||
let hasChanges = false;
|
||||
@@ -50,15 +64,50 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
payload.appProfileId = appProfileId as number;
|
||||
}
|
||||
|
||||
if (priority !== null) {
|
||||
hasChanges = true;
|
||||
payload.priority = priority as number;
|
||||
}
|
||||
|
||||
if (minimumSeeders !== null) {
|
||||
hasChanges = true;
|
||||
payload.minimumSeeders = minimumSeeders as number;
|
||||
}
|
||||
|
||||
if (seedRatio !== null) {
|
||||
hasChanges = true;
|
||||
payload.seedRatio = seedRatio as number;
|
||||
}
|
||||
|
||||
if (seedTime !== null) {
|
||||
hasChanges = true;
|
||||
payload.seedTime = seedTime as number;
|
||||
}
|
||||
|
||||
if (packSeedTime !== null) {
|
||||
hasChanges = true;
|
||||
payload.packSeedTime = packSeedTime as number;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
onSavePress(payload);
|
||||
}
|
||||
|
||||
onModalClose();
|
||||
}, [enable, appProfileId, onSavePress, onModalClose]);
|
||||
}, [
|
||||
enable,
|
||||
appProfileId,
|
||||
priority,
|
||||
minimumSeeders,
|
||||
seedRatio,
|
||||
seedTime,
|
||||
packSeedTime,
|
||||
onSavePress,
|
||||
onModalClose,
|
||||
]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }) => {
|
||||
({ name, value }: { name: string; value: string }) => {
|
||||
switch (name) {
|
||||
case 'enable':
|
||||
setEnable(value);
|
||||
@@ -66,8 +115,23 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
case 'appProfileId':
|
||||
setAppProfileId(value);
|
||||
break;
|
||||
case 'priority':
|
||||
setPriority(value);
|
||||
break;
|
||||
case 'minimumSeeders':
|
||||
setMinimumSeeders(value);
|
||||
break;
|
||||
case 'seedRatio':
|
||||
setSeedRatio(value);
|
||||
break;
|
||||
case 'seedTime':
|
||||
setSeedTime(value);
|
||||
break;
|
||||
case 'packSeedTime':
|
||||
setPackSeedTime(value);
|
||||
break;
|
||||
default:
|
||||
console.warn('EditIndexerModalContent Unknown Input');
|
||||
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
|
||||
}
|
||||
},
|
||||
[setEnable]
|
||||
@@ -81,10 +145,10 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('Edit Selected Indexer')}</ModalHeader>
|
||||
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
@@ -96,30 +160,95 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('SyncProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.APP_PROFILE_SELECT}
|
||||
name="appProfileId"
|
||||
value={appProfileId}
|
||||
helpText={translate('AppProfileSelectHelpText')}
|
||||
includeNoChange={true}
|
||||
includeNoChangeDisabled={false}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('IndexerPriority')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="priority"
|
||||
value={priority}
|
||||
min={1}
|
||||
max={50}
|
||||
helpText={translate('IndexerPriorityHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('AppsMinimumSeeders')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="minimumSeeders"
|
||||
value={minimumSeeders}
|
||||
helpText={translate('AppsMinimumSeedersHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('SeedRatio')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="seedRatio"
|
||||
value={seedRatio}
|
||||
helpText={translate('SeedRatioHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('SeedTime')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="seedTime"
|
||||
value={seedTime}
|
||||
unit={translate('minutes')}
|
||||
helpText={translate('SeedTimeHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('PackSeedTime')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="packSeedTime"
|
||||
value={packSeedTime}
|
||||
unit={translate('minutes')}
|
||||
helpText={translate('PackSeedTimeHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div className={styles.selected}>
|
||||
{translate('{0} indexers selected', selectedCount.toString())}
|
||||
{translate('CountIndexersSelected', [selectedCount])}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button onPress={onSavePressWrapper}>
|
||||
{translate('Apply Changes')}
|
||||
{translate('ApplyChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -7,7 +7,7 @@ import translate from 'Utilities/String/translate';
|
||||
interface IndexerIndexSelectAllButtonProps {
|
||||
label: string;
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
}
|
||||
|
||||
function IndexerIndexSelectAllButton(props: IndexerIndexSelectAllButtonProps) {
|
||||
@@ -25,9 +25,7 @@ function IndexerIndexSelectAllButton(props: IndexerIndexSelectAllButtonProps) {
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
selectDispatch({
|
||||
type: allSelected
|
||||
? SelectActionType.UnselectAll
|
||||
: SelectActionType.SelectAll,
|
||||
type: allSelected ? 'unselectAll' : 'selectAll',
|
||||
});
|
||||
}, [allSelected, selectDispatch]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -26,9 +26,7 @@ function IndexerIndexSelectAllMenuItem(
|
||||
|
||||
const onPressWrapper = useCallback(() => {
|
||||
selectDispatch({
|
||||
type: allSelected
|
||||
? SelectActionType.UnselectAll
|
||||
: SelectActionType.SelectAll,
|
||||
type: allSelected ? 'unselectAll' : 'selectAll',
|
||||
});
|
||||
}, [allSelected, selectDispatch]);
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { saveIndexerEditor } from 'Store/Actions/indexerIndexActions';
|
||||
import { bulkEditIndexers } from 'Store/Actions/indexerActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import DeleteIndexerModal from './Delete/DeleteIndexerModal';
|
||||
@@ -13,8 +15,18 @@ import EditIndexerModal from './Edit/EditIndexerModal';
|
||||
import TagsModal from './Tags/TagsModal';
|
||||
import styles from './IndexerIndexSelectFooter.css';
|
||||
|
||||
const seriesEditorSelector = createSelector(
|
||||
(state) => state.indexers,
|
||||
interface SavePayload {
|
||||
enable?: boolean;
|
||||
appProfileId?: number;
|
||||
priority?: number;
|
||||
minimumSeeders?: number;
|
||||
seedRatio?: number;
|
||||
seedTime?: number;
|
||||
packSeedTime?: number;
|
||||
}
|
||||
|
||||
const indexersEditorSelector = createSelector(
|
||||
(state: AppState) => state.indexers,
|
||||
(indexers) => {
|
||||
const { isSaving, isDeleting, deleteError } = indexers;
|
||||
|
||||
@@ -27,8 +39,9 @@ const seriesEditorSelector = createSelector(
|
||||
);
|
||||
|
||||
function IndexerIndexSelectFooter() {
|
||||
const { isSaving, isDeleting, deleteError } =
|
||||
useSelector(seriesEditorSelector);
|
||||
const { isSaving, isDeleting, deleteError } = useSelector(
|
||||
indexersEditorSelector
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -37,6 +50,7 @@ function IndexerIndexSelectFooter() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isSavingIndexer, setIsSavingIndexer] = useState(false);
|
||||
const [isSavingTags, setIsSavingTags] = useState(false);
|
||||
const previousIsDeleting = usePrevious(isDeleting);
|
||||
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const { selectedState } = selectState;
|
||||
@@ -56,14 +70,14 @@ function IndexerIndexSelectFooter() {
|
||||
}, [setIsEditModalOpen]);
|
||||
|
||||
const onSavePress = useCallback(
|
||||
(payload) => {
|
||||
(payload: SavePayload) => {
|
||||
setIsSavingIndexer(true);
|
||||
setIsEditModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
saveIndexerEditor({
|
||||
bulkEditIndexers({
|
||||
...payload,
|
||||
indexerIds,
|
||||
ids: indexerIds,
|
||||
})
|
||||
);
|
||||
},
|
||||
@@ -79,13 +93,13 @@ function IndexerIndexSelectFooter() {
|
||||
}, [setIsTagsModalOpen]);
|
||||
|
||||
const onApplyTagsPress = useCallback(
|
||||
(tags, applyTags) => {
|
||||
(tags: number[], applyTags: string) => {
|
||||
setIsSavingTags(true);
|
||||
setIsTagsModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
saveIndexerEditor({
|
||||
indexerIds,
|
||||
bulkEditIndexers({
|
||||
ids: indexerIds,
|
||||
tags,
|
||||
applyTags,
|
||||
})
|
||||
@@ -110,10 +124,10 @@ function IndexerIndexSelectFooter() {
|
||||
}, [isSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeleting && !deleteError) {
|
||||
selectDispatch({ type: SelectActionType.UnselectAll });
|
||||
if (previousIsDeleting && !isDeleting && !deleteError) {
|
||||
selectDispatch({ type: 'unselectAll' });
|
||||
}
|
||||
}, [isDeleting, deleteError, selectDispatch]);
|
||||
}, [previousIsDeleting, isDeleting, deleteError, selectDispatch]);
|
||||
|
||||
const anySelected = selectedCount > 0;
|
||||
|
||||
@@ -134,7 +148,7 @@ function IndexerIndexSelectFooter() {
|
||||
isDisabled={!anySelected}
|
||||
onPress={onTagsPress}
|
||||
>
|
||||
{translate('Set Tags')}
|
||||
{translate('SetTags')}
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +165,7 @@ function IndexerIndexSelectFooter() {
|
||||
</div>
|
||||
|
||||
<div className={styles.selected}>
|
||||
{translate('{0} indexers selected', selectedCount.toString())}
|
||||
{translate('CountIndexersSelected', [selectedCount])}
|
||||
</div>
|
||||
|
||||
<EditIndexerModal
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||
import React, { useCallback } from 'react';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
|
||||
interface IndexerIndexSelectModeButtonProps {
|
||||
label: string;
|
||||
iconName: IconDefinition;
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ function IndexerIndexSelectModeButton(
|
||||
const onPressWrapper = useCallback(() => {
|
||||
if (isSelectMode) {
|
||||
selectDispatch({
|
||||
type: SelectActionType.Reset,
|
||||
type: 'reset',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +13,7 @@ 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';
|
||||
@@ -26,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<number[]>([]);
|
||||
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]
|
||||
@@ -59,14 +67,14 @@ 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 (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>Tags</ModalHeader>
|
||||
<ModalHeader>{translate('Tags')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
@@ -90,10 +98,10 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||
value={applyTags}
|
||||
values={applyTagsOptions}
|
||||
helpTexts={[
|
||||
translate('ApplyTagsHelpTexts1'),
|
||||
translate('ApplyTagsHelpTexts2'),
|
||||
translate('ApplyTagsHelpTexts3'),
|
||||
translate('ApplyTagsHelpTexts4'),
|
||||
translate('ApplyTagsHelpTextHowToApplyIndexers'),
|
||||
translate('ApplyTagsHelpTextAdd'),
|
||||
translate('ApplyTagsHelpTextRemove'),
|
||||
translate('ApplyTagsHelpTextReplace'),
|
||||
]}
|
||||
onChange={onApplyTagsChange}
|
||||
/>
|
||||
@@ -119,8 +127,8 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||
key={tag.id}
|
||||
title={
|
||||
removeTag
|
||||
? translate('RemoveTagRemovingTag')
|
||||
: translate('RemoveTagExistingTag')
|
||||
? translate('RemovingTag')
|
||||
: translate('ExistingTag')
|
||||
}
|
||||
kind={removeTag ? kinds.INVERSE : kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
@@ -159,10 +167,10 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
|
||||
Apply
|
||||
{translate('Apply')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
|
||||
.priority,
|
||||
.protocol,
|
||||
.privacy {
|
||||
.privacy,
|
||||
.minimumSeeders,
|
||||
.seedRatio,
|
||||
.seedTime,
|
||||
.packSeedTime {
|
||||
composes: cell;
|
||||
|
||||
flex: 0 0 90px;
|
||||
|
||||
@@ -8,9 +8,13 @@ interface CssExports {
|
||||
'cell': string;
|
||||
'checkInput': string;
|
||||
'externalLink': string;
|
||||
'minimumSeeders': string;
|
||||
'packSeedTime': string;
|
||||
'priority': string;
|
||||
'privacy': string;
|
||||
'protocol': string;
|
||||
'seedRatio': string;
|
||||
'seedTime': string;
|
||||
'sortName': string;
|
||||
'status': string;
|
||||
'tags': string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
@@ -13,6 +13,7 @@ import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal';
|
||||
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
|
||||
import createIndexerIndexItemSelector from 'Indexer/Index/createIndexerIndexItemSelector';
|
||||
import IndexerTitleLink from 'Indexer/IndexerTitleLink';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CapabilitiesLabel from './CapabilitiesLabel';
|
||||
@@ -55,6 +56,23 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
const vipExpiration =
|
||||
fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
|
||||
|
||||
const minimumSeeders =
|
||||
fields.find(
|
||||
(field) => field.name === 'torrentBaseSettings.appMinimumSeeders'
|
||||
)?.value ?? undefined;
|
||||
|
||||
const seedRatio =
|
||||
fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')
|
||||
?.value ?? undefined;
|
||||
|
||||
const seedTime =
|
||||
fields.find((field) => field.name === 'torrentBaseSettings.seedTime')
|
||||
?.value ?? undefined;
|
||||
|
||||
const packSeedTime =
|
||||
fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')
|
||||
?.value ?? undefined;
|
||||
|
||||
const rssUrl = `${window.location.origin}${
|
||||
window.Prowlarr.urlBase
|
||||
}/${id}/api?apikey=${encodeURIComponent(
|
||||
@@ -83,14 +101,10 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
setIsDeleteIndexerModalOpen(false);
|
||||
}, [setIsDeleteIndexerModalOpen]);
|
||||
|
||||
const checkInputCallback = useCallback(() => {
|
||||
// Mock handler to satisfy `onChange` being required for `CheckInput`.
|
||||
}, []);
|
||||
|
||||
const onSelectedChange = useCallback(
|
||||
({ id, value, shiftKey }) => {
|
||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
||||
selectDispatch({
|
||||
type: SelectActionType.ToggleSelected,
|
||||
type: 'toggleSelected',
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
@@ -185,6 +199,8 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
|
||||
if (name === 'added') {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ts(2739)
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
@@ -196,6 +212,8 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
|
||||
if (name === 'vipExpiration') {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ts(2739)
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
@@ -213,10 +231,44 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'minimumSeeders') {
|
||||
return (
|
||||
<VirtualTableRowCell key={name} className={styles[name]}>
|
||||
{minimumSeeders}
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'seedRatio') {
|
||||
return (
|
||||
<VirtualTableRowCell key={name} className={styles[name]}>
|
||||
{seedRatio}
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'seedTime') {
|
||||
return (
|
||||
<VirtualTableRowCell key={name} className={styles[name]}>
|
||||
{seedTime}
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'packSeedTime') {
|
||||
return (
|
||||
<VirtualTableRowCell key={name} className={styles[name]}>
|
||||
{packSeedTime}
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<VirtualTableRowCell
|
||||
key={column.name}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ts(2739)
|
||||
className={styles[column.name]}
|
||||
>
|
||||
<IconButton
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
.tableScroller {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
transition: background-color 500ms;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--tableRowHoverBackgroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'row': string;
|
||||
'tableScroller': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { throttle } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import Column from 'Components/Table/Column';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
@@ -13,7 +14,6 @@ import dimensions from 'Styles/Variables/dimensions';
|
||||
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
|
||||
import IndexerIndexRow from './IndexerIndexRow';
|
||||
import IndexerIndexTableHeader from './IndexerIndexTableHeader';
|
||||
import selectTableOptions from './selectTableOptions';
|
||||
import styles from './IndexerIndexTable.css';
|
||||
|
||||
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
|
||||
@@ -30,17 +30,17 @@ interface RowItemData {
|
||||
|
||||
interface IndexerIndexTableProps {
|
||||
items: Indexer[];
|
||||
sortKey?: string;
|
||||
sortKey: string;
|
||||
sortDirection?: SortDirection;
|
||||
jumpToCharacter?: string;
|
||||
scrollTop?: number;
|
||||
scrollerRef: React.MutableRefObject<HTMLElement>;
|
||||
scrollerRef: RefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
const columnsSelector = createSelector(
|
||||
(state) => state.indexerIndex.columns,
|
||||
(state: AppState) => state.indexerIndex.columns,
|
||||
(columns) => columns
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||
justifyContent: 'space-between',
|
||||
...style,
|
||||
}}
|
||||
className={styles.row}
|
||||
>
|
||||
<IndexerIndexRow
|
||||
indexerId={indexer.id}
|
||||
@@ -91,19 +92,16 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
} = props;
|
||||
|
||||
const columns = useSelector(columnsSelector);
|
||||
const { showBanners } = useSelector(selectTableOptions);
|
||||
const listRef: React.MutableRefObject<List> = useRef();
|
||||
const listRef = useRef<List<RowItemData>>(null);
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
const rowHeight = useMemo(() => {
|
||||
return showBanners ? 70 : 38;
|
||||
}, [showBanners]);
|
||||
const rowHeight = 38;
|
||||
|
||||
useEffect(() => {
|
||||
const current = scrollerRef.current as HTMLElement;
|
||||
const current = scrollerRef?.current as HTMLElement;
|
||||
|
||||
if (isSmallScreen) {
|
||||
setSize({
|
||||
@@ -127,8 +125,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
}, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
|
||||
const currentScrollerRef = scrollerRef.current;
|
||||
const currentScrollerRef = scrollerRef.current as HTMLElement;
|
||||
const currentScrollListener = isSmallScreen ? window : currentScrollerRef;
|
||||
|
||||
const handleScroll = throttle(() => {
|
||||
const { offsetTop = 0 } = currentScrollerRef;
|
||||
@@ -137,7 +135,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
? getWindowScrollTopPosition()
|
||||
: currentScrollerRef.scrollTop) - offsetTop;
|
||||
|
||||
listRef.current.scrollTo(scrollTop);
|
||||
listRef.current?.scrollTo(scrollTop);
|
||||
}, 10);
|
||||
|
||||
currentScrollListener.addEventListener('scroll', handleScroll);
|
||||
@@ -166,8 +164,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
scrollTop += offset;
|
||||
}
|
||||
|
||||
listRef.current.scrollTo(scrollTop);
|
||||
scrollerRef.current.scrollTo(0, scrollTop);
|
||||
listRef.current?.scrollTo(scrollTop);
|
||||
scrollerRef?.current?.scrollTo(0, scrollTop);
|
||||
}
|
||||
}
|
||||
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);
|
||||
@@ -179,7 +177,6 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
scrollDirection={ScrollDirection.Horizontal}
|
||||
>
|
||||
<IndexerIndexTableHeader
|
||||
showBanners={showBanners}
|
||||
columns={columns}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
|
||||
.priority,
|
||||
.privacy,
|
||||
.protocol {
|
||||
.protocol,
|
||||
.minimumSeeders,
|
||||
.seedRatio,
|
||||
.seedTime,
|
||||
.packSeedTime {
|
||||
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||
|
||||
flex: 0 0 90px;
|
||||
|
||||
@@ -5,9 +5,13 @@ interface CssExports {
|
||||
'added': string;
|
||||
'appProfileId': string;
|
||||
'capabilities': string;
|
||||
'minimumSeeders': string;
|
||||
'packSeedTime': string;
|
||||
'priority': string;
|
||||
'privacy': string;
|
||||
'protocol': string;
|
||||
'seedRatio': string;
|
||||
'seedTime': string;
|
||||
'sortName': string;
|
||||
'status': string;
|
||||
'tags': string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { SelectActionType, useSelect } from 'App/SelectContext';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Column from 'Components/Table/Column';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
setIndexerSort,
|
||||
setIndexerTableOption,
|
||||
} from 'Store/Actions/indexerIndexActions';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import IndexerIndexTableOptions from './IndexerIndexTableOptions';
|
||||
import styles from './IndexerIndexTableHeader.css';
|
||||
|
||||
interface IndexerIndexTableHeaderProps {
|
||||
showBanners: boolean;
|
||||
columns: Column[];
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
@@ -31,23 +31,23 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) {
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
|
||||
const onSortPress = useCallback(
|
||||
(value) => {
|
||||
(value: string) => {
|
||||
dispatch(setIndexerSort({ sortKey: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onTableOptionChange = useCallback(
|
||||
(payload) => {
|
||||
(payload: unknown) => {
|
||||
dispatch(setIndexerTableOption(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onSelectAllChange = useCallback(
|
||||
({ value }) => {
|
||||
({ value }: CheckInputChanged) => {
|
||||
selectDispatch({
|
||||
type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll,
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
});
|
||||
},
|
||||
[selectDispatch]
|
||||
@@ -92,7 +92,11 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) {
|
||||
return (
|
||||
<VirtualTableHeaderCell
|
||||
key={name}
|
||||
className={classNames(styles[name])}
|
||||
className={classNames(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
styles[name]
|
||||
)}
|
||||
name={name}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
|
||||
@@ -4,6 +4,7 @@ import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import selectTableOptions from './selectTableOptions';
|
||||
|
||||
@@ -19,7 +20,7 @@ function IndexerIndexTableOptions(props: IndexerIndexTableOptionsProps) {
|
||||
const { showSearchAction } = tableOptions;
|
||||
|
||||
const onTableOptionChangeWrapper = useCallback(
|
||||
({ name, value }) => {
|
||||
({ name, value }: CheckInputChanged) => {
|
||||
onTableOptionChange({
|
||||
tableOptions: {
|
||||
...tableOptions,
|
||||
|
||||
@@ -12,7 +12,7 @@ interface IndexerStatusCellProps {
|
||||
className: string;
|
||||
enabled: boolean;
|
||||
redirect: boolean;
|
||||
status: IndexerStatus;
|
||||
status?: IndexerStatus;
|
||||
longDateFormat: string;
|
||||
timeFormat: string;
|
||||
component?: React.ElementType;
|
||||
@@ -43,7 +43,7 @@ function IndexerStatusCell(props: IndexerStatusCellProps) {
|
||||
className={styles.statusIcon}
|
||||
kind={enabled ? enableKind : kinds.DEFAULT}
|
||||
name={enabled ? enableIcon : icons.BLOCKLIST}
|
||||
title={enabled ? enableTitle : translate('EnabledIndexerIsDisabled')}
|
||||
title={enabled ? enableTitle : translate('Disabled')}
|
||||
/>
|
||||
}
|
||||
{status ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user