summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLiang Yi <[email protected]>2022-01-22 21:35:11 +0800
committerGitHub <[email protected]>2022-01-22 21:35:11 +0800
commitd8d2300980ca69a4ae6511cb49a6dc548c0da793 (patch)
tree23f2f136c495b4064f43a0c4148391c46b9fa997
parent6b82a734e2bc597b219472774c0ec58038630c65 (diff)
downloadbazarr-d8d2300980ca69a4ae6511cb49a6dc548c0da793.tar.gz
bazarr-d8d2300980ca69a4ae6511cb49a6dc548c0da793.zip
Add React-Query to improve network and cache performancev1.0.3-beta.15
-rw-r--r--frontend/.env.development3
-rw-r--r--frontend/package-lock.json203
-rw-r--r--frontend/package.json2
-rw-r--r--frontend/src/@modules/socketio/reducer.ts144
-rw-r--r--frontend/src/@modules/task/hooks.ts17
-rw-r--r--frontend/src/@redux/__tests__/entity-reducer.test.ts406
-rw-r--r--frontend/src/@redux/__tests__/item-reducer.test.ts155
-rw-r--r--frontend/src/@redux/__tests__/list-reducer.test.ts252
-rw-r--r--frontend/src/@redux/actions/index.ts42
-rw-r--r--frontend/src/@redux/actions/movie.ts84
-rw-r--r--frontend/src/@redux/actions/series.ts106
-rw-r--r--frontend/src/@redux/actions/site.ts62
-rw-r--r--frontend/src/@redux/actions/system.ts87
-rw-r--r--frontend/src/@redux/hooks/async.ts29
-rw-r--r--frontend/src/@redux/hooks/index.ts43
-rw-r--r--frontend/src/@redux/hooks/movies.ts67
-rw-r--r--frontend/src/@redux/hooks/series.ts99
-rw-r--r--frontend/src/@redux/hooks/site.ts39
-rw-r--r--frontend/src/@redux/hooks/system.ts131
-rw-r--r--frontend/src/@redux/reducers/index.ts119
-rw-r--r--frontend/src/@redux/reducers/movie.ts68
-rw-r--r--frontend/src/@redux/reducers/series.ts100
-rw-r--r--frontend/src/@redux/reducers/site.ts130
-rw-r--r--frontend/src/@redux/reducers/system.ts74
-rw-r--r--frontend/src/@redux/store/index.ts2
-rw-r--r--frontend/src/@redux/tests/helper.ts31
-rw-r--r--frontend/src/@redux/utils/__tests__/async-test.ts32
-rw-r--r--frontend/src/@redux/utils/factory.ts339
-rw-r--r--frontend/src/@redux/utils/index.ts88
-rw-r--r--frontend/src/@types/api.d.ts2
-rw-r--r--frontend/src/@types/async.d.ts24
-rw-r--r--frontend/src/@types/function.d.ts3
-rw-r--r--frontend/src/@types/site.d.ts1
-rw-r--r--frontend/src/@types/utilities.d.ts2
-rw-r--r--frontend/src/App/Header.tsx68
-rw-r--r--frontend/src/App/Notification.tsx6
-rw-r--r--frontend/src/App/index.tsx48
-rw-r--r--frontend/src/Blacklist/Movies/index.tsx39
-rw-r--r--frontend/src/Blacklist/Series/index.tsx39
-rw-r--r--frontend/src/DisplayItem/generic/BaseItemView/index.tsx177
-rw-r--r--frontend/src/DisplayItem/generic/BaseItemView/table.tsx78
-rw-r--r--frontend/src/History/Statistics/index.tsx149
-rw-r--r--frontend/src/History/generic/index.tsx40
-rw-r--r--frontend/src/Navigation/RootRedirect.tsx2
-rw-r--r--frontend/src/Navigation/index.ts66
-rw-r--r--frontend/src/Router/index.tsx4
-rw-r--r--frontend/src/Sidebar/index.tsx18
-rw-r--r--frontend/src/System/Logs/index.tsx66
-rw-r--r--frontend/src/System/Providers/index.tsx50
-rw-r--r--frontend/src/System/Tasks/index.tsx42
-rw-r--r--frontend/src/Wanted/generic/index.tsx65
-rw-r--r--frontend/src/__tests__/render-test.tsx4
-rw-r--r--frontend/src/apis/hooks.ts31
-rw-r--r--frontend/src/apis/hooks/episodes.ts115
-rw-r--r--frontend/src/apis/hooks/histories.ts21
-rw-r--r--frontend/src/apis/hooks/index.ts9
-rw-r--r--frontend/src/apis/hooks/languages.ts23
-rw-r--r--frontend/src/apis/hooks/movies.ts138
-rw-r--r--frontend/src/apis/hooks/providers.ts99
-rw-r--r--frontend/src/apis/hooks/series.ts80
-rw-r--r--frontend/src/apis/hooks/status.ts18
-rw-r--r--frontend/src/apis/hooks/subtitles.ts119
-rw-r--r--frontend/src/apis/hooks/system.ts188
-rw-r--r--frontend/src/apis/queries/client.ts (renamed from frontend/src/apis/index.ts)23
-rw-r--r--frontend/src/apis/queries/hooks.ts116
-rw-r--r--frontend/src/apis/queries/index.ts14
-rw-r--r--frontend/src/apis/queries/keys.ts23
-rw-r--r--frontend/src/apis/raw/badges.ts (renamed from frontend/src/apis/badges.ts)0
-rw-r--r--frontend/src/apis/raw/base.ts (renamed from frontend/src/apis/base.ts)10
-rw-r--r--frontend/src/apis/raw/episodes.ts (renamed from frontend/src/apis/episodes.ts)10
-rw-r--r--frontend/src/apis/raw/files.ts (renamed from frontend/src/apis/files.ts)0
-rw-r--r--frontend/src/apis/raw/history.ts (renamed from frontend/src/apis/history.ts)4
-rw-r--r--frontend/src/apis/raw/index.ts25
-rw-r--r--frontend/src/apis/raw/movies.ts (renamed from frontend/src/apis/movies.ts)26
-rw-r--r--frontend/src/apis/raw/providers.ts (renamed from frontend/src/apis/providers.ts)0
-rw-r--r--frontend/src/apis/raw/series.ts (renamed from frontend/src/apis/series.ts)9
-rw-r--r--frontend/src/apis/raw/subtitles.ts (renamed from frontend/src/apis/subtitles.ts)0
-rw-r--r--frontend/src/apis/raw/system.ts (renamed from frontend/src/apis/system.ts)2
-rw-r--r--frontend/src/apis/raw/utils.ts (renamed from frontend/src/apis/utils.ts)6
-rw-r--r--frontend/src/components/ErrorBoundary.tsx (renamed from frontend/src/special-pages/ErrorBoundary.tsx)2
-rw-r--r--frontend/src/components/ItemOverview.tsx (renamed from frontend/src/DisplayItem/generic/ItemOverview.tsx)11
-rw-r--r--frontend/src/components/LanguageSelector.tsx2
-rw-r--r--frontend/src/components/SearchBar.tsx69
-rw-r--r--frontend/src/components/async.tsx73
-rw-r--r--frontend/src/components/inputs/FileBrowser.tsx57
-rw-r--r--frontend/src/components/inputs/blacklist.tsx (renamed from frontend/src/DisplayItem/generic/blacklist.tsx)2
-rw-r--r--frontend/src/components/modals/HistoryModal.tsx107
-rw-r--r--frontend/src/components/modals/ItemEditorModal.tsx14
-rw-r--r--frontend/src/components/modals/ManualSearchModal.tsx87
-rw-r--r--frontend/src/components/modals/MovieUploadModal.tsx30
-rw-r--r--frontend/src/components/modals/SeriesUploadModal.tsx39
-rw-r--r--frontend/src/components/modals/SubtitleToolModal.tsx26
-rw-r--r--frontend/src/components/modals/SubtitleUploadModal.tsx2
-rw-r--r--frontend/src/components/modals/hooks.tsx2
-rw-r--r--frontend/src/components/tables/AsyncPageTable.tsx128
-rw-r--r--frontend/src/components/tables/PageTable.tsx2
-rw-r--r--frontend/src/components/tables/QueryPageTable.tsx77
-rw-r--r--frontend/src/components/tables/index.tsx2
-rw-r--r--frontend/src/components/tables/plugins/useDefaultSettings.tsx2
-rw-r--r--frontend/src/components/views/HistoryView.tsx36
-rw-r--r--frontend/src/components/views/ItemView.tsx213
-rw-r--r--frontend/src/components/views/WantedView.tsx60
-rw-r--r--frontend/src/index.tsx21
-rw-r--r--frontend/src/pages/404.tsx (renamed from frontend/src/special-pages/404.tsx)0
-rw-r--r--frontend/src/pages/Authentication.scss (renamed from frontend/src/special-pages/AuthPage.scss)0
-rw-r--r--frontend/src/pages/Authentication.tsx (renamed from frontend/src/special-pages/AuthPage.tsx)62
-rw-r--r--frontend/src/pages/Blacklist/Movies/index.tsx40
-rw-r--r--frontend/src/pages/Blacklist/Movies/table.tsx (renamed from frontend/src/Blacklist/Movies/table.tsx)22
-rw-r--r--frontend/src/pages/Blacklist/Series/index.tsx39
-rw-r--r--frontend/src/pages/Blacklist/Series/table.tsx (renamed from frontend/src/Blacklist/Series/table.tsx)23
-rw-r--r--frontend/src/pages/Episodes/components.tsx (renamed from frontend/src/DisplayItem/Episodes/components.tsx)35
-rw-r--r--frontend/src/pages/Episodes/index.tsx (renamed from frontend/src/DisplayItem/Episodes/index.tsx)134
-rw-r--r--frontend/src/pages/Episodes/table.tsx (renamed from frontend/src/DisplayItem/Episodes/table.tsx)120
-rw-r--r--frontend/src/pages/History/Movies/index.tsx (renamed from frontend/src/History/Movies/index.tsx)32
-rw-r--r--frontend/src/pages/History/Series/index.tsx (renamed from frontend/src/History/Series/index.tsx)33
-rw-r--r--frontend/src/pages/History/Statistics/index.tsx126
-rw-r--r--frontend/src/pages/History/Statistics/options.ts (renamed from frontend/src/History/Statistics/options.ts)2
-rw-r--r--frontend/src/pages/LaunchError.tsx (renamed from frontend/src/special-pages/LaunchError.tsx)2
-rw-r--r--frontend/src/pages/Movies/Details/index.tsx (renamed from frontend/src/DisplayItem/MovieDetail/index.tsx)138
-rw-r--r--frontend/src/pages/Movies/Details/table.tsx (renamed from frontend/src/DisplayItem/MovieDetail/table.tsx)46
-rw-r--r--frontend/src/pages/Movies/index.tsx (renamed from frontend/src/DisplayItem/Movies/index.tsx)35
-rw-r--r--frontend/src/pages/Series/index.tsx (renamed from frontend/src/DisplayItem/Series/index.tsx)36
-rw-r--r--frontend/src/pages/Settings/General/index.tsx (renamed from frontend/src/Settings/General/index.tsx)2
-rw-r--r--frontend/src/pages/Settings/General/options.ts (renamed from frontend/src/Settings/General/options.ts)0
-rw-r--r--frontend/src/pages/Settings/Languages/components.tsx (renamed from frontend/src/Settings/Languages/components.tsx)2
-rw-r--r--frontend/src/pages/Settings/Languages/index.tsx (renamed from frontend/src/Settings/Languages/index.tsx)15
-rw-r--r--frontend/src/pages/Settings/Languages/modal.tsx (renamed from frontend/src/Settings/Languages/modal.tsx)22
-rw-r--r--frontend/src/pages/Settings/Languages/options.ts (renamed from frontend/src/Settings/Languages/options.ts)0
-rw-r--r--frontend/src/pages/Settings/Languages/table.tsx (renamed from frontend/src/Settings/Languages/table.tsx)2
-rw-r--r--frontend/src/pages/Settings/Notifications/components.tsx (renamed from frontend/src/Settings/Notifications/components.tsx)22
-rw-r--r--frontend/src/pages/Settings/Notifications/index.tsx (renamed from frontend/src/Settings/Notifications/index.tsx)0
-rw-r--r--frontend/src/pages/Settings/Providers/components.tsx (renamed from frontend/src/Settings/Providers/components.tsx)16
-rw-r--r--frontend/src/pages/Settings/Providers/index.tsx (renamed from frontend/src/Settings/Providers/index.tsx)0
-rw-r--r--frontend/src/pages/Settings/Providers/list.ts (renamed from frontend/src/Settings/Providers/list.ts)0
-rw-r--r--frontend/src/pages/Settings/Radarr/index.tsx (renamed from frontend/src/Settings/Radarr/index.tsx)2
-rw-r--r--frontend/src/pages/Settings/Scheduler/index.tsx (renamed from frontend/src/Settings/Scheduler/index.tsx)0
-rw-r--r--frontend/src/pages/Settings/Scheduler/options.ts (renamed from frontend/src/Settings/Scheduler/options.ts)0
-rw-r--r--frontend/src/pages/Settings/Sonarr/index.tsx (renamed from frontend/src/Settings/Sonarr/index.tsx)2
-rw-r--r--frontend/src/pages/Settings/Subtitles/index.tsx (renamed from frontend/src/Settings/Subtitles/index.tsx)3
-rw-r--r--frontend/src/pages/Settings/Subtitles/options.ts (renamed from frontend/src/Settings/Subtitles/options.ts)0
-rw-r--r--frontend/src/pages/Settings/UI/index.tsx (renamed from frontend/src/Settings/UI/index.tsx)2
-rw-r--r--frontend/src/pages/Settings/UI/options.ts (renamed from frontend/src/Settings/UI/options.ts)0
-rw-r--r--frontend/src/pages/Settings/components/collapse.tsx (renamed from frontend/src/Settings/components/collapse.tsx)0
-rw-r--r--frontend/src/pages/Settings/components/container.tsx (renamed from frontend/src/Settings/components/container.tsx)0
-rw-r--r--frontend/src/pages/Settings/components/forms.tsx (renamed from frontend/src/Settings/components/forms.tsx)18
-rw-r--r--frontend/src/pages/Settings/components/hooks.ts (renamed from frontend/src/Settings/components/hooks.ts)6
-rw-r--r--frontend/src/pages/Settings/components/index.tsx (renamed from frontend/src/Settings/components/index.tsx)11
-rw-r--r--frontend/src/pages/Settings/components/pathMapper.tsx (renamed from frontend/src/Settings/components/pathMapper.tsx)17
-rw-r--r--frontend/src/pages/Settings/components/provider.tsx (renamed from frontend/src/Settings/components/provider.tsx)43
-rw-r--r--frontend/src/pages/Settings/components/style.scss (renamed from frontend/src/Settings/components/style.scss)0
-rw-r--r--frontend/src/pages/Settings/keys.ts (renamed from frontend/src/Settings/keys.ts)0
-rw-r--r--frontend/src/pages/Settings/options.ts (renamed from frontend/src/Settings/options.ts)0
-rw-r--r--frontend/src/pages/System/Logs/index.tsx55
-rw-r--r--frontend/src/pages/System/Logs/modal.tsx (renamed from frontend/src/System/Logs/modal.tsx)2
-rw-r--r--frontend/src/pages/System/Logs/table.tsx (renamed from frontend/src/System/Logs/table.tsx)2
-rw-r--r--frontend/src/pages/System/Providers/index.tsx48
-rw-r--r--frontend/src/pages/System/Providers/table.tsx (renamed from frontend/src/System/Providers/table.tsx)2
-rw-r--r--frontend/src/pages/System/Releases/index.tsx (renamed from frontend/src/System/Releases/index.tsx)37
-rw-r--r--frontend/src/pages/System/Status/index.tsx (renamed from frontend/src/System/Status/index.tsx)16
-rw-r--r--frontend/src/pages/System/Status/style.scss (renamed from frontend/src/System/Status/style.scss)0
-rw-r--r--frontend/src/pages/System/Status/table.tsx (renamed from frontend/src/System/Status/table.tsx)2
-rw-r--r--frontend/src/pages/System/Tasks/index.tsx39
-rw-r--r--frontend/src/pages/System/Tasks/table.tsx (renamed from frontend/src/System/Tasks/table.tsx)7
-rw-r--r--frontend/src/pages/UIError.tsx (renamed from frontend/src/special-pages/UIError.tsx)4
-rw-r--r--frontend/src/pages/Wanted/Movies/index.tsx (renamed from frontend/src/Wanted/Movies/index.tsx)59
-rw-r--r--frontend/src/pages/Wanted/Series/index.tsx (renamed from frontend/src/Wanted/Series/index.tsx)60
-rw-r--r--frontend/src/utilities/async.ts78
-rw-r--r--frontend/src/utilities/constants.ts (renamed from frontend/src/constants.ts)0
-rw-r--r--frontend/src/utilities/entity.ts63
-rw-r--r--frontend/src/utilities/env.ts6
-rw-r--r--frontend/src/utilities/index.ts12
-rw-r--r--frontend/src/utilities/languages.ts49
-rw-r--r--frontend/src/utilities/storage.ts (renamed from frontend/src/@storage/local.ts)0
-rw-r--r--frontend/tsconfig.json13
174 files changed, 3177 insertions, 4607 deletions
diff --git a/frontend/.env.development b/frontend/.env.development
index e36629b67..a12b1d357 100644
--- a/frontend/.env.development
+++ b/frontend/.env.development
@@ -22,3 +22,6 @@ REACT_APP_CAN_UPDATE=true
# Display update notification in notification center
REACT_APP_HAS_UPDATE=false
+
+# Display React-Query devtools
+REACT_APP_QUERY_DEV=false
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7a9d54892..6c3c858b8 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,6 +25,7 @@
"react-bootstrap": "^1",
"react-dom": "^17",
"react-helmet": "^6.1",
+ "react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^5.3",
"react-scripts": "^4",
@@ -45,7 +46,6 @@
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
- "@types/react-select": "^5.0.1",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"husky": "^7",
@@ -3668,16 +3668,6 @@
"@types/react-router": "*"
}
},
- "node_modules/@types/react-select": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz",
- "integrity": "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA==",
- "deprecated": "This is a stub types definition. react-select provides its own type definitions, so you do not need this installed.",
- "dev": true,
- "dependencies": {
- "react-select": "*"
- }
- },
"node_modules/@types/react-table": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.9.tgz",
@@ -5389,6 +5379,14 @@
"node": ">= 8.0.0"
}
},
+ "node_modules/big-integer": {
+ "version": "1.6.51",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -5531,6 +5529,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/broadcast-channel": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
+ "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
+ "dependencies": {
+ "@babel/runtime": "^7.7.2",
+ "detect-node": "^2.1.0",
+ "js-sha3": "0.8.0",
+ "microseconds": "0.2.0",
+ "nano-time": "1.0.0",
+ "oblivious-set": "1.0.0",
+ "rimraf": "3.0.2",
+ "unload": "2.2.0"
+ }
+ },
"node_modules/brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@@ -5863,9 +5876,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001249",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz",
- "integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw==",
+ "version": "1.0.30001300",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz",
+ "integrity": "sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
@@ -13013,6 +13026,11 @@
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==",
"peer": true
},
+ "node_modules/js-sha3": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
+ "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -13457,6 +13475,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/match-sorter": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
+ "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "remove-accents": "0.4.2"
+ }
+ },
"node_modules/md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -13548,6 +13575,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/microseconds": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
+ "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
+ },
"node_modules/miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
@@ -13884,6 +13916,14 @@
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
"optional": true
},
+ "node_modules/nano-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
+ "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
+ "dependencies": {
+ "big-integer": "^1.6.16"
+ }
+ },
"node_modules/nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
@@ -14335,6 +14375,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/oblivious-set": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
+ "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
+ },
"node_modules/obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@@ -17063,6 +17108,31 @@
"react-dom": ">=16.3.0"
}
},
+ "node_modules/react-query": {
+ "version": "3.34.8",
+ "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.8.tgz",
+ "integrity": "sha512-pl9e2VmVbgKf29Qn/WpmFVtB2g17JPqLLyOQg3GfSs/S2WABvip5xlT464vfXtilLPcJVg9bEHHlqmC38/nvDw==",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "broadcast-channel": "^3.4.1",
+ "match-sorter": "^6.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-redux": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
@@ -17718,6 +17788,11 @@
"node": ">= 0.10"
}
},
+ "node_modules/remove-accents": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+ "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+ },
"node_modules/remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -20520,6 +20595,15 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/unload": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
+ "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
+ "dependencies": {
+ "@babel/runtime": "^7.6.2",
+ "detect-node": "^2.0.4"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -24979,15 +25063,6 @@
"@types/react-router": "*"
}
},
- "@types/react-select": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz",
- "integrity": "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA==",
- "dev": true,
- "requires": {
- "react-select": "*"
- }
- },
"@types/react-table": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.9.tgz",
@@ -26329,6 +26404,11 @@
"tryer": "^1.0.1"
}
},
+ "big-integer": {
+ "version": "1.6.51",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg=="
+ },
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -26447,6 +26527,21 @@
}
}
},
+ "broadcast-channel": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
+ "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
+ "requires": {
+ "@babel/runtime": "^7.7.2",
+ "detect-node": "^2.1.0",
+ "js-sha3": "0.8.0",
+ "microseconds": "0.2.0",
+ "nano-time": "1.0.0",
+ "oblivious-set": "1.0.0",
+ "rimraf": "3.0.2",
+ "unload": "2.2.0"
+ }
+ },
"brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@@ -26718,9 +26813,9 @@
}
},
"caniuse-lite": {
- "version": "1.0.30001249",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz",
- "integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw=="
+ "version": "1.0.30001300",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz",
+ "integrity": "sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA=="
},
"capture-exit": {
"version": "2.0.0",
@@ -32115,6 +32210,11 @@
"integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==",
"peer": true
},
+ "js-sha3": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
+ "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
+ },
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -32470,6 +32570,15 @@
"object-visit": "^1.0.0"
}
},
+ "match-sorter": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
+ "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "remove-accents": "0.4.2"
+ }
+ },
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -32549,6 +32658,11 @@
"to-regex": "^3.0.2"
}
},
+ "microseconds": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
+ "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
+ },
"miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
@@ -32808,6 +32922,14 @@
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
"optional": true
},
+ "nano-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
+ "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
+ "requires": {
+ "big-integer": "^1.6.16"
+ }
+ },
"nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
@@ -33162,6 +33284,11 @@
"es-abstract": "^1.18.2"
}
},
+ "oblivious-set": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
+ "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
+ },
"obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@@ -35316,6 +35443,16 @@
"warning": "^4.0.3"
}
},
+ "react-query": {
+ "version": "3.34.8",
+ "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.8.tgz",
+ "integrity": "sha512-pl9e2VmVbgKf29Qn/WpmFVtB2g17JPqLLyOQg3GfSs/S2WABvip5xlT464vfXtilLPcJVg9bEHHlqmC38/nvDw==",
+ "requires": {
+ "@babel/runtime": "^7.5.5",
+ "broadcast-channel": "^3.4.1",
+ "match-sorter": "^6.0.2"
+ }
+ },
"react-redux": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
@@ -35835,6 +35972,11 @@
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
"integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk="
},
+ "remove-accents": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+ "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+ },
"remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -38030,6 +38172,15 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
},
+ "unload": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
+ "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
+ "requires": {
+ "@babel/runtime": "^7.6.2",
+ "detect-node": "^2.0.4"
+ }
+ },
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 63911691e..5cd64d49e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,6 +30,7 @@
"react-bootstrap": "^1",
"react-dom": "^17",
"react-helmet": "^6.1",
+ "react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^5.3",
"react-scripts": "^4",
@@ -50,7 +51,6 @@
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
- "@types/react-select": "^5.0.1",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"husky": "^7",
diff --git a/frontend/src/@modules/socketio/reducer.ts b/frontend/src/@modules/socketio/reducer.ts
index 13e2f2459..0e5b52be8 100644
--- a/frontend/src/@modules/socketio/reducer.ts
+++ b/frontend/src/@modules/socketio/reducer.ts
@@ -1,33 +1,14 @@
import { ActionCreator } from "@reduxjs/toolkit";
+import { QueryKeys } from "apis/queries/keys";
import {
- episodesMarkBlacklistDirty,
- episodesMarkDirtyById,
- episodesRemoveById,
- episodesResetHistory,
- movieMarkBlacklistDirty,
- movieMarkDirtyById,
- movieMarkWantedDirtyById,
- movieRemoveById,
- movieRemoveWantedById,
- movieResetHistory,
- movieResetWanted,
- seriesMarkDirtyById,
- seriesMarkWantedDirtyById,
- seriesRemoveById,
- seriesRemoveWantedById,
- seriesResetWanted,
- siteAddNotifications,
+ addNotifications,
+ setOfflineStatus,
+ setSiteStatus,
siteAddProgress,
- siteBootstrap,
siteRemoveProgress,
- siteUpdateBadges,
- siteUpdateInitialization,
- siteUpdateOffline,
- systemMarkTasksDirty,
- systemUpdateAllSettings,
- systemUpdateLanguages,
} from "../../@redux/actions";
import reduxStore from "../../@redux/store";
+import queryClient from "../../apis/queries";
function bindReduxAction<T extends ActionCreator<any>>(action: T) {
return (...args: Parameters<T>) => {
@@ -48,26 +29,24 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
return [
{
key: "connect",
- any: bindReduxActionWithParam(siteUpdateOffline, false),
+ any: bindReduxActionWithParam(setOfflineStatus, false),
},
{
key: "connect",
- any: bindReduxAction(siteBootstrap),
+ any: () => {
+ // init
+ reduxStore.dispatch(setSiteStatus("initialized"));
+ },
},
{
key: "connect_error",
any: () => {
- const initialized = reduxStore.getState().site.initialized;
- if (initialized === true) {
- reduxStore.dispatch(siteUpdateOffline(true));
- } else {
- reduxStore.dispatch(siteUpdateInitialization("Socket.IO Error"));
- }
+ reduxStore.dispatch(setSiteStatus("error"));
},
},
{
key: "disconnect",
- any: bindReduxActionWithParam(siteUpdateOffline, true),
+ any: bindReduxActionWithParam(setOfflineStatus, true),
},
{
key: "message",
@@ -80,7 +59,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
timeout: 5 * 1000,
}));
- reduxStore.dispatch(siteAddNotifications(notifications));
+ reduxStore.dispatch(addNotifications(notifications));
}
},
},
@@ -91,68 +70,125 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
},
{
key: "series",
- update: bindReduxAction(seriesMarkDirtyById),
- delete: bindReduxAction(seriesRemoveById),
+ update: (ids) => {
+ ids.forEach((id) => {
+ queryClient.invalidateQueries([QueryKeys.Series, id]);
+ });
+ },
+ delete: (ids) => {
+ ids.forEach((id) => {
+ queryClient.invalidateQueries([QueryKeys.Series, id]);
+ });
+ },
},
{
key: "movie",
- update: bindReduxAction(movieMarkDirtyById),
- delete: bindReduxAction(movieRemoveById),
+ update: (ids) => {
+ ids.forEach((id) => {
+ queryClient.invalidateQueries([QueryKeys.Movies, id]);
+ });
+ },
+ delete: (ids) => {
+ ids.forEach((id) => {
+ queryClient.invalidateQueries([QueryKeys.Movies, id]);
+ });
+ },
},
{
key: "episode",
- update: bindReduxAction(episodesMarkDirtyById),
- delete: bindReduxAction(episodesRemoveById),
+ update: (ids) => {
+ ids.forEach((id) => {
+ queryClient.invalidateQueries([QueryKeys.Episodes, id]);
+ });
+ },
+ delete: (ids) => {
+ ids.forEach((id) => {
+ queryClient.invalidateQueries([QueryKeys.Episodes, id]);
+ });
+ },
},
{
key: "episode-wanted",
- update: bindReduxAction(seriesMarkWantedDirtyById),
- delete: bindReduxAction(seriesRemoveWantedById),
+ update: (ids) => {
+ // Find a better way to update wanted
+ queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
+ },
+ delete: () => {
+ queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
+ },
},
{
key: "movie-wanted",
- update: bindReduxAction(movieMarkWantedDirtyById),
- delete: bindReduxAction(movieRemoveWantedById),
+ update: (ids) => {
+ // Find a better way to update wanted
+ queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
+ },
+ delete: () => {
+ queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
+ },
},
{
key: "settings",
- any: bindReduxAction(systemUpdateAllSettings),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.System]);
+ },
},
{
key: "languages",
- any: bindReduxAction(systemUpdateLanguages),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Languages]);
+ },
},
{
key: "badges",
- any: bindReduxAction(siteUpdateBadges),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Badges]);
+ },
},
{
key: "movie-history",
- any: bindReduxAction(movieResetHistory),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.History]);
+ },
},
{
key: "movie-blacklist",
- any: bindReduxAction(movieMarkBlacklistDirty),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
+ },
},
{
key: "episode-history",
- any: bindReduxAction(episodesResetHistory),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.History]);
+ },
},
{
key: "episode-blacklist",
- any: bindReduxAction(episodesMarkBlacklistDirty),
+ any: () => {
+ queryClient.invalidateQueries([
+ QueryKeys.Episodes,
+ QueryKeys.Blacklist,
+ ]);
+ },
},
{
key: "reset-episode-wanted",
- any: bindReduxAction(seriesResetWanted),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
+ },
},
{
key: "reset-movie-wanted",
- any: bindReduxAction(movieResetWanted),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
+ },
},
{
key: "task",
- any: bindReduxAction(systemMarkTasksDirty),
+ any: () => {
+ queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
+ },
},
];
}
diff --git a/frontend/src/@modules/task/hooks.ts b/frontend/src/@modules/task/hooks.ts
deleted file mode 100644
index 557146dd2..000000000
--- a/frontend/src/@modules/task/hooks.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import BGT from "./";
-
-export function useIsAnyTaskRunning() {
- return BGT.isRunning();
-}
-
-export function useIsAnyTaskRunningWithId(ids: number[]) {
- return BGT.hasId(ids);
-}
-
-export function useIsGroupTaskRunning(groupName: string) {
- return BGT.has(groupName);
-}
-
-export function useIsGroupTaskRunningWithId(groupName: string, id: number) {
- return BGT.find(groupName, id);
-}
diff --git a/frontend/src/@redux/__tests__/entity-reducer.test.ts b/frontend/src/@redux/__tests__/entity-reducer.test.ts
deleted file mode 100644
index cf1be21c3..000000000
--- a/frontend/src/@redux/__tests__/entity-reducer.test.ts
+++ /dev/null
@@ -1,406 +0,0 @@
-import {
- configureStore,
- createAction,
- createAsyncThunk,
- createReducer,
-} from "@reduxjs/toolkit";
-import {} from "jest";
-import { differenceWith, intersectionWith, isString, uniq } from "lodash";
-import { defaultList, defaultState, TestType } from "../tests/helper";
-import { createAsyncEntityReducer } from "../utils/factory";
-
-const newItem: TestType = {
- id: 123,
- name: "extended",
-};
-
-const longerList: TestType[] = [...defaultList, newItem];
-const shorterList: TestType[] = defaultList.slice(0, defaultList.length - 1);
-
-const allResolved = createAsyncThunk("all/resolved", () => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({ total: defaultList.length, data: defaultList });
- });
-});
-
-const allResolvedLonger = createAsyncThunk("all/longer/resolved", () => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({ total: longerList.length, data: longerList });
- });
-});
-
-const allResolvedShorter = createAsyncThunk("all/shorter/resolved", () => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({ total: shorterList.length, data: shorterList });
- });
-});
-
-const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({
- total: defaultList.length,
- data: intersectionWith(defaultList, param, (l, r) => l.id === r),
- });
- });
-});
-
-const idsResolvedLonger = createAsyncThunk(
- "ids/longer/resolved",
- (param: number[]) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({
- total: longerList.length,
- data: intersectionWith(longerList, param, (l, r) => l.id === r),
- });
- });
- }
-);
-
-const idsResolvedShorter = createAsyncThunk(
- "ids/shorter/resolved",
- (param: number[]) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({
- total: shorterList.length,
- data: intersectionWith(shorterList, param, (l, r) => l.id === r),
- });
- });
- }
-);
-
-const rangeResolved = createAsyncThunk(
- "range/resolved",
- (param: Parameter.Range) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({
- total: defaultList.length,
- data: defaultList.slice(param.start, param.start + param.length),
- });
- });
- }
-);
-
-const rangeResolvedLonger = createAsyncThunk(
- "range/longer/resolved",
- (param: Parameter.Range) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({
- total: longerList.length,
- data: longerList.slice(param.start, param.start + param.length),
- });
- });
- }
-);
-
-const rangeResolvedShorter = createAsyncThunk(
- "range/shorter/resolved",
- (param: Parameter.Range) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
- resolve({
- total: shorterList.length,
- data: shorterList.slice(param.start, param.start + param.length),
- });
- });
- }
-);
-
-const allRejected = createAsyncThunk("all/rejected", () => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
- rejected("Error");
- });
-});
-const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
- rejected("Error");
- });
-});
-const rangeRejected = createAsyncThunk(
- "range/rejected",
- (param: Parameter.Range) => {
- return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
- rejected("Error");
- });
- }
-);
-const removeIds = createAction<number[]>("remove/id");
-const dirty = createAction<number[]>("dirty/id");
-const reset = createAction("reset");
-
-const reducer = createReducer(defaultState, (builder) => {
- createAsyncEntityReducer(builder, (s) => s.entities, {
- all: allResolved,
- range: rangeResolved,
- ids: idsResolved,
- dirty,
- removeIds,
- reset,
- });
- createAsyncEntityReducer(builder, (s) => s.entities, {
- all: allRejected,
- range: rangeRejected,
- ids: idsRejected,
- });
-
- createAsyncEntityReducer(builder, (s) => s.entities, {
- all: allResolvedLonger,
- range: rangeResolvedLonger,
- ids: idsResolvedLonger,
- });
-
- createAsyncEntityReducer(builder, (s) => s.entities, {
- all: allResolvedShorter,
- range: rangeResolvedShorter,
- ids: idsResolvedShorter,
- });
-});
-
-function createStore() {
- const store = configureStore({
- reducer,
- });
- expect(store.getState()).toEqual(defaultState);
- return store;
-}
-
-let store = createStore();
-
-function use(callback: (entities: Async.Entity<TestType>) => void) {
- const entities = store.getState().entities;
- callback(entities);
-}
-
-beforeEach(() => {
- store = createStore();
-});
-
-it("entity update all resolved", async () => {
- await store.dispatch(allResolved());
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.error).toBeNull();
- expect(entities.state).toBe("succeeded");
- defaultList.forEach((v, index) => {
- const id = v.id.toString();
- expect(entities.content.ids[index]).toEqual(id);
- expect(entities.content.entities[id]).toEqual(v);
- expect(entities.didLoaded).toContain(id);
- });
- });
-});
-
-it("entity update all rejected", async () => {
- await store.dispatch(allRejected());
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.error).not.toBeNull();
- expect(entities.state).toBe("failed");
- expect(entities.content.ids).toHaveLength(0);
- expect(entities.content.entities).toEqual({});
- });
-});
-
-it("entity reset", async () => {
- await store.dispatch(allResolved());
- store.dispatch(reset());
- use((entities) => {
- expect(entities).toEqual(defaultState.entities);
- });
-});
-
-it("entity mark dirty", async () => {
- await store.dispatch(allResolved());
-
- store.dispatch(dirty([1, 2, 3]));
- use((entities) => {
- expect(entities.error).toBeNull();
- expect(entities.state).toBe("dirty");
- defaultList.forEach((v, index) => {
- const id = v.id.toString();
- expect(entities.content.ids[index]).toEqual(id);
- expect(entities.content.entities[id]).toEqual(v);
- });
- });
-});
-
-it("delete entity item", async () => {
- await store.dispatch(allResolved());
-
- const idsToRemove = [0, 1, 3, 5];
- const expectResults = differenceWith(
- defaultList,
- idsToRemove,
- (l, r) => l.id === r
- );
-
- store.dispatch(removeIds(idsToRemove));
- use((entities) => {
- expect(entities.state).toBe("succeeded");
- idsToRemove.map(String).forEach((v) => {
- expect(entities.didLoaded).not.toContain(v);
- });
- expectResults.forEach((v, index) => {
- const id = v.id.toString();
- expect(entities.content.ids[index]).toEqual(id);
- expect(entities.content.entities[id]).toEqual(v);
- });
- });
-});
-
-it("entity update by range", async () => {
- await store.dispatch(rangeResolved({ start: 0, length: 2 }));
- await store.dispatch(rangeResolved({ start: 4, length: 2 }));
- use((entities) => {
- expect(entities.content.ids).toHaveLength(defaultList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(4);
- [0, 1, 4, 5].forEach((v) => {
- const id = v.toString();
- expect(entities.content.ids).toContain(id);
- expect(entities.content.entities[id].id).toEqual(v);
- expect(entities.didLoaded).toContain(id);
- });
- expect(entities.error).toBeNull();
- expect(entities.state).toBe("succeeded");
- });
-});
-
-it("entity update by duplicative range", async () => {
- await store.dispatch(rangeResolved({ start: 0, length: 2 }));
- await store.dispatch(rangeResolved({ start: 1, length: 2 }));
- use((entities) => {
- expect(entities.content.ids).toHaveLength(defaultList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(3);
- defaultList.slice(0, 3).forEach((v) => {
- const id = v.id.toString();
- expect(entities.content.ids).toContain(id);
- expect(entities.content.entities[id]).toEqual(v);
- expect(entities.didLoaded.filter((v) => v === id)).toHaveLength(1);
- });
- expect(entities.error).toBeNull();
- expect(entities.state).toBe("succeeded");
- });
-});
-
-it("entity update by range and ids", async () => {
- await store.dispatch(rangeResolved({ start: 0, length: 2 }));
- await store.dispatch(idsResolved([3]));
- await store.dispatch(rangeResolved({ start: 2, length: 2 }));
- use((entries) => {
- const ids = entries.content.ids.filter(isString);
- const dedupIds = uniq(ids);
- expect(ids.length).toBe(dedupIds.length);
- });
-});
-
-it("entity resolved by dirty", async () => {
- await store.dispatch(rangeResolved({ start: 0, length: 2 }));
- store.dispatch(dirty([1, 2, 3]));
- await store.dispatch(rangeResolved({ start: 1, length: 2 }));
- use((entities) => {
- expect(entities.dirtyEntities).not.toContain("1");
- expect(entities.dirtyEntities).not.toContain("2");
- expect(entities.dirtyEntities).toContain("3");
- expect(entities.state).toBe("dirty");
- });
- await store.dispatch(rangeResolved({ start: 1, length: 3 }));
- use((entities) => {
- expect(entities.dirtyEntities).not.toContain("1");
- expect(entities.dirtyEntities).not.toContain("2");
- expect(entities.dirtyEntities).not.toContain("3");
- expect(entities.state).toBe("succeeded");
- });
-});
-
-it("entity update by ids", async () => {
- await store.dispatch(idsResolved([999]));
- use((entities) => {
- expect(entities.content.ids).toHaveLength(defaultList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(0);
- expect(entities.content.entities).not.toHaveProperty("999");
- expect(entities.error).toBeNull();
- expect(entities.state).toBe("succeeded");
- });
-});
-
-it("entity resolved dirty by ids", async () => {
- await store.dispatch(idsResolved([0, 1, 2, 3, 4]));
- store.dispatch(dirty([0, 1, 2, 3]));
- await store.dispatch(idsResolved([0, 1]));
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(2);
- expect(entities.content.ids.filter(isString)).toHaveLength(5);
- expect(entities.error).toBeNull();
- expect(entities.state).toBe("dirty");
- });
-});
-
-it("entity resolved non-exist by ids", async () => {
- await store.dispatch(idsResolved([0, 1]));
- store.dispatch(dirty([999]));
- await store.dispatch(idsResolved([999]));
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.state).toBe("succeeded");
- });
-});
-
-it("entity update by variant range", async () => {
- await store.dispatch(allResolved());
-
- await store.dispatch(rangeResolvedLonger({ start: 0, length: 2 }));
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.state).toBe("succeeded");
- expect(entities.content.ids).toHaveLength(longerList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(2);
- longerList.slice(0, 2).forEach((v) => {
- const id = v.id.toString();
- expect(entities.content.ids).toContain(id);
- expect(entities.content.entities[id]).toEqual(v);
- });
- });
-
- await store.dispatch(allResolved());
- await store.dispatch(rangeResolvedShorter({ start: 0, length: 2 }));
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.state).toBe("succeeded");
- expect(entities.content.ids).toHaveLength(shorterList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(2);
- shorterList.slice(0, 2).forEach((v) => {
- const id = v.id.toString();
- expect(entities.content.ids).toContain(id);
- expect(entities.content.entities[id]).toEqual(v);
- });
- });
-});
-
-it("entity update by variant ids", async () => {
- await store.dispatch(allResolved());
-
- await store.dispatch(idsResolvedLonger([2, 3, 4]));
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.state).toBe("succeeded");
- expect(entities.content.ids).toHaveLength(longerList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(3);
- Array(3)
- .fill(undefined)
- .forEach((v) => {
- expect(entities.content.ids[v]).not.toBeNull();
- });
- });
-
- await store.dispatch(allResolved());
- await store.dispatch(idsResolvedShorter([2, 3, 4]));
- use((entities) => {
- expect(entities.dirtyEntities).toHaveLength(0);
- expect(entities.state).toBe("succeeded");
- expect(entities.content.ids).toHaveLength(shorterList.length);
- expect(entities.content.ids.filter(isString)).toHaveLength(3);
- Array(3)
- .fill(undefined)
- .forEach((v) => {
- expect(entities.content.ids[v]).not.toBeNull();
- });
- });
-});
diff --git a/frontend/src/@redux/__tests__/item-reducer.test.ts b/frontend/src/@redux/__tests__/item-reducer.test.ts
deleted file mode 100644
index 60b8f3462..000000000
--- a/frontend/src/@redux/__tests__/item-reducer.test.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import {
- configureStore,
- createAction,
- createAsyncThunk,
- createReducer,
-} from "@reduxjs/toolkit";
-import {} from "jest";
-import { defaultState, TestType } from "../tests/helper";
-import { createAsyncItemReducer } from "../utils/factory";
-
-// Item
-const defaultItem: TestType = { id: 0, name: "test" };
-const allResolved = createAsyncThunk("all/resolved", () => {
- return new Promise<TestType>((resolve) => {
- resolve(defaultItem);
- });
-});
-const allRejected = createAsyncThunk("all/rejected", () => {
- return new Promise<TestType>((resolve, rejected) => {
- rejected("Error");
- });
-});
-const dirty = createAction("dirty/ids");
-
-const reducer = createReducer(defaultState, (builder) => {
- createAsyncItemReducer(builder, (s) => s.item, { all: allResolved, dirty });
- createAsyncItemReducer(builder, (s) => s.item, { all: allRejected });
-});
-
-function createStore() {
- const store = configureStore({
- reducer,
- });
- expect(store.getState()).toEqual(defaultState);
- return store;
-}
-
-let store = createStore();
-
-function use(callback: (entities: Async.Item<TestType>) => void) {
- const item = store.getState().item;
- callback(item);
-}
-
-// Begin Test Section
-
-beforeEach(() => {
- store = createStore();
-});
-
-it("item loading", async () => {
- return new Promise<void>((done) => {
- store.dispatch(allResolved()).finally(() => {
- use((item) => {
- expect(item.error).toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
- done();
- });
- use((item) => {
- expect(item.state).toBe("loading");
- expect(item.error).toBeNull();
- expect(item.content).toBeNull();
- });
- });
-});
-
-it("item uninitialized -> succeeded", async () => {
- await store.dispatch(allResolved());
- use((item) => {
- expect(item.state).toBe("succeeded");
- expect(item.error).toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
-});
-
-it("item uninitialized -> failed", async () => {
- await store.dispatch(allRejected());
- use((item) => {
- expect(item.state).toBe("failed");
- expect(item.error).not.toBeNull();
- expect(item.content).toBeNull();
- });
-});
-
-it("item uninitialized -> dirty", () => {
- store.dispatch(dirty());
- use((item) => {
- expect(item.state).toBe("uninitialized");
- expect(item.error).toBeNull();
- expect(item.content).toBeNull();
- });
-});
-
-it("item succeeded -> failed", async () => {
- await store.dispatch(allResolved());
- await store.dispatch(allRejected());
- use((item) => {
- expect(item.state).toBe("failed");
- expect(item.error).not.toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
-});
-
-it("item failed -> succeeded", async () => {
- await store.dispatch(allRejected());
- await store.dispatch(allResolved());
- use((item) => {
- expect(item.state).toBe("succeeded");
- expect(item.error).toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
-});
-
-it("item succeeded -> dirty", async () => {
- await store.dispatch(allResolved());
- store.dispatch(dirty());
- use((item) => {
- expect(item.state).toBe("dirty");
- expect(item.error).toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
-});
-
-it("item failed -> dirty", async () => {
- await store.dispatch(allRejected());
- store.dispatch(dirty());
- use((item) => {
- expect(item.state).toBe("dirty");
- expect(item.error).not.toBeNull();
- expect(item.content).toBeNull();
- });
-});
-
-it("item dirty -> failed", async () => {
- await store.dispatch(allResolved());
- store.dispatch(dirty());
- await store.dispatch(allRejected());
- use((item) => {
- expect(item.state).toBe("failed");
- expect(item.error).not.toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
-});
-
-it("item dirty -> succeeded", async () => {
- await store.dispatch(allResolved());
- store.dispatch(dirty());
- await store.dispatch(allResolved());
- use((item) => {
- expect(item.state).toBe("succeeded");
- expect(item.error).toBeNull();
- expect(item.content).toEqual(defaultItem);
- });
-});
diff --git a/frontend/src/@redux/__tests__/list-reducer.test.ts b/frontend/src/@redux/__tests__/list-reducer.test.ts
deleted file mode 100644
index d94bfd164..000000000
--- a/frontend/src/@redux/__tests__/list-reducer.test.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import {
- configureStore,
- createAction,
- createAsyncThunk,
- createReducer,
-} from "@reduxjs/toolkit";
-import {} from "jest";
-import { intersectionWith } from "lodash";
-import { defaultList, defaultState, TestType } from "../tests/helper";
-import { createAsyncListReducer } from "../utils/factory";
-
-const allResolved = createAsyncThunk("all/resolved", () => {
- return new Promise<TestType[]>((resolve) => {
- resolve(defaultList);
- });
-});
-const allRejected = createAsyncThunk("all/rejected", () => {
- return new Promise<TestType[]>((resolve, rejected) => {
- rejected("Error");
- });
-});
-const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
- return new Promise<TestType[]>((resolve) => {
- resolve(intersectionWith(defaultList, param, (l, r) => l.id === r));
- });
-});
-const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
- return new Promise<TestType[]>((resolve, rejected) => {
- rejected("Error");
- });
-});
-const removeIds = createAction<number[]>("remove/id");
-const dirty = createAction<number[]>("dirty/id");
-
-const reducer = createReducer(defaultState, (builder) => {
- createAsyncListReducer(builder, (s) => s.list, {
- all: allResolved,
- ids: idsResolved,
- removeIds,
- dirty,
- });
- createAsyncListReducer(builder, (s) => s.list, {
- all: allRejected,
- ids: idsRejected,
- });
-});
-
-function createStore() {
- const store = configureStore({
- reducer,
- });
- expect(store.getState()).toEqual(defaultState);
- return store;
-}
-
-let store = createStore();
-
-function use(callback: (list: Async.List<TestType>) => void) {
- const list = store.getState().list;
- callback(list);
-}
-
-beforeEach(() => {
- store = createStore();
-});
-
-it("list all uninitialized -> succeeded", async () => {
- await store.dispatch(allResolved());
- use((list) => {
- expect(list.content).toEqual(defaultList);
- expect(list.dirtyEntities).toHaveLength(0);
- expect(list.didLoaded).toHaveLength(defaultList.length);
- expect(list.error).toBeNull();
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list all uninitialized -> failed", async () => {
- await store.dispatch(allRejected());
- use((list) => {
- expect(list.content).toHaveLength(0);
- expect(list.dirtyEntities).toHaveLength(0);
- expect(list.error).not.toBeNull();
- expect(list.state).toEqual("failed");
- });
-});
-
-it("list uninitialized -> dirty", () => {
- store.dispatch(dirty([0, 1]));
- use((list) => {
- expect(list.content).toHaveLength(0);
- expect(list.dirtyEntities).toHaveLength(0);
- expect(list.error).toBeNull();
- expect(list.state).toEqual("uninitialized");
- });
-});
-
-it("list succeeded -> dirty", async () => {
- await store.dispatch(allResolved());
- store.dispatch(dirty([1, 2, 3]));
- use((list) => {
- expect(list.content).toEqual(defaultList);
- expect(list.dirtyEntities).toHaveLength(3);
- expect(list.error).toBeNull();
- expect(list.state).toEqual("dirty");
- });
-});
-
-it("list ids uninitialized -> succeeded", async () => {
- await store.dispatch(idsResolved([0, 1, 2]));
- use((list) => {
- expect(list.content).toHaveLength(3);
- expect(list.didLoaded).toHaveLength(3);
- expect(list.dirtyEntities).toHaveLength(0);
- expect(list.error).toBeNull();
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list ids succeeded -> dirty", async () => {
- await store.dispatch(idsResolved([0, 1]));
- store.dispatch(dirty([2, 3]));
- use((list) => {
- expect(list.dirtyEntities).toHaveLength(2);
- expect(list.state).toEqual("dirty");
- });
-});
-
-it("list ids succeeded -> dirty", async () => {
- await store.dispatch(idsResolved([0, 1, 2]));
- store.dispatch(dirty([2, 3]));
- use((list) => {
- expect(list.dirtyEntities).toHaveLength(2);
- expect(list.state).toEqual("dirty");
- });
-});
-
-it("list ids update data", async () => {
- await store.dispatch(idsResolved([0, 1]));
- await store.dispatch(idsResolved([3, 4]));
- use((list) => {
- expect(list.content).toHaveLength(4);
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list ids update duplicative data", async () => {
- await store.dispatch(idsResolved([0, 1, 2]));
- await store.dispatch(idsResolved([2, 3]));
- use((list) => {
- expect(list.content).toHaveLength(4);
- expect(list.didLoaded).toHaveLength(4);
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list ids update new data", async () => {
- await store.dispatch(idsResolved([0, 1]));
- await store.dispatch(idsResolved([2, 3]));
- use((list) => {
- expect(list.content).toHaveLength(4);
- expect(list.didLoaded).toHaveLength(4);
- expect(list.content[1].id).toBe(2);
- expect(list.content[0].id).toBe(3);
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list ids empty data", async () => {
- await store.dispatch(idsResolved([0, 1, 2]));
- await store.dispatch(idsResolved([999]));
- use((list) => {
- expect(list.content).toHaveLength(3);
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list ids duplicative dirty", async () => {
- await store.dispatch(idsResolved([0]));
- store.dispatch(dirty([2, 2]));
- use((list) => {
- expect(list.dirtyEntities).toHaveLength(1);
- expect(list.dirtyEntities).toContain("2");
- expect(list.state).toEqual("dirty");
- });
-});
-
-it("list ids resolved dirty", async () => {
- await store.dispatch(idsResolved([0, 1, 2]));
- store.dispatch(dirty([2, 3]));
- use((list) => {
- expect(list.content).toHaveLength(3);
- expect(list.dirtyEntities).toContain("2");
- expect(list.dirtyEntities).toContain("3");
- expect(list.state).toBe("dirty");
- });
-});
-
-it("list ids resolved dirty", async () => {
- await store.dispatch(idsResolved([0, 1, 2]));
- store.dispatch(dirty([1, 2, 3, 999]));
- await store.dispatch(idsResolved([1, 2]));
- use((list) => {
- expect(list.content).toHaveLength(3);
- expect(list.dirtyEntities).not.toContain("1");
- expect(list.dirtyEntities).not.toContain("2");
- expect(list.state).toBe("dirty");
- });
-
- await store.dispatch(idsResolved([3]));
- use((list) => {
- expect(list.content).toHaveLength(4);
- expect(list.dirtyEntities).not.toContain("3");
- expect(list.state).toBe("dirty");
- });
-
- await store.dispatch(idsResolved([999]));
- use((list) => {
- expect(list.content).toHaveLength(4);
- expect(list.dirtyEntities).not.toContain("999");
- expect(list.state).toBe("succeeded");
- });
-});
-
-it("list remove ids", async () => {
- await store.dispatch(allResolved());
- const totalSize = store.getState().list.content.length;
-
- store.dispatch(removeIds([1, 2]));
- use((list) => {
- expect(list.content).toHaveLength(totalSize - 2);
- expect(list.content.map((v) => v.id)).not.toContain(1);
- expect(list.content.map((v) => v.id)).not.toContain(2);
- expect(list.state).toEqual("succeeded");
- });
-});
-
-it("list remove dirty ids", async () => {
- await store.dispatch(allResolved());
- store.dispatch(dirty([1, 2, 3]));
- store.dispatch(removeIds([1, 2]));
- use((list) => {
- expect(list.dirtyEntities).not.toContain("1");
- expect(list.dirtyEntities).not.toContain("2");
- expect(list.state).toEqual("dirty");
- });
- store.dispatch(removeIds([3]));
- use((list) => {
- expect(list.dirtyEntities).toHaveLength(0);
- expect(list.state).toEqual("succeeded");
- });
-});
diff --git a/frontend/src/@redux/actions/index.ts b/frontend/src/@redux/actions/index.ts
index afb0e5255..db44d8a74 100644
--- a/frontend/src/@redux/actions/index.ts
+++ b/frontend/src/@redux/actions/index.ts
@@ -1,4 +1,38 @@
-export * from "./movie";
-export * from "./series";
-export * from "./site";
-export * from "./system";
+import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
+import { waitFor } from "../../utilities";
+
+export const setSiteStatus = createAction<Site.Status>("site/status/update");
+
+export const setUnauthenticated = createAction("site/unauthenticated");
+
+export const setOfflineStatus = createAction<boolean>("site/offline/update");
+
+export const addNotifications = createAction<Server.Notification[]>(
+ "site/notifications/add"
+);
+
+export const removeNotification = createAction<string>(
+ "site/notifications/remove"
+);
+
+export const siteAddProgress =
+ createAction<Site.Progress[]>("site/progress/add");
+
+export const siteUpdateProgressCount = createAction<{
+ id: string;
+ count: number;
+}>("site/progress/update_count");
+
+export const siteRemoveProgress = createAsyncThunk(
+ "site/progress/remove",
+ async (ids: string[]) => {
+ await waitFor(3 * 1000);
+ return ids;
+ }
+);
+
+export const siteUpdateNotifier = createAction<string>(
+ "site/progress/update_notifier"
+);
+
+export const setSidebar = createAction<boolean>("site/sidebar/update");
diff --git a/frontend/src/@redux/actions/movie.ts b/frontend/src/@redux/actions/movie.ts
deleted file mode 100644
index d37dcfdcb..000000000
--- a/frontend/src/@redux/actions/movie.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
-import { MoviesApi } from "../../apis";
-
-export const movieUpdateByRange = createAsyncThunk(
- "movies/update/range",
- async (params: Parameter.Range) => {
- const response = await MoviesApi.moviesBy(params);
- return response;
- }
-);
-
-export const movieUpdateById = createAsyncThunk(
- "movies/update/id",
- async (ids: number[]) => {
- const response = await MoviesApi.movies(ids);
- return response;
- }
-);
-
-export const movieUpdateAll = createAsyncThunk(
- "movies/update/all",
- async () => {
- const response = await MoviesApi.movies();
- return response;
- }
-);
-
-export const movieRemoveById = createAction<number[]>("movies/remove");
-
-export const movieMarkDirtyById = createAction<number[]>(
- "movies/mark_dirty/id"
-);
-
-export const movieUpdateWantedById = createAsyncThunk(
- "movies/wanted/update/id",
- async (ids: number[]) => {
- const response = await MoviesApi.wantedBy(ids);
- return response;
- }
-);
-
-export const movieRemoveWantedById = createAction<number[]>(
- "movies/wanted/remove/id"
-);
-
-export const movieResetWanted = createAction("movies/wanted/reset");
-
-export const movieMarkWantedDirtyById = createAction<number[]>(
- "movies/wanted/mark_dirty/id"
-);
-
-export const movieUpdateWantedByRange = createAsyncThunk(
- "movies/wanted/update/range",
- async (params: Parameter.Range) => {
- const response = await MoviesApi.wanted(params);
- return response;
- }
-);
-
-export const movieUpdateHistoryByRange = createAsyncThunk(
- "movies/history/update/range",
- async (params: Parameter.Range) => {
- const response = await MoviesApi.history(params);
- return response;
- }
-);
-
-export const movieMarkHistoryDirty = createAction<number[]>(
- "movies/history/mark_dirty"
-);
-
-export const movieResetHistory = createAction("movie/history/reset");
-
-export const movieUpdateBlacklist = createAsyncThunk(
- "movies/blacklist/update",
- async () => {
- const response = await MoviesApi.blacklist();
- return response;
- }
-);
-
-export const movieMarkBlacklistDirty = createAction(
- "movies/blacklist/mark_dirty"
-);
diff --git a/frontend/src/@redux/actions/series.ts b/frontend/src/@redux/actions/series.ts
deleted file mode 100644
index cc80780f2..000000000
--- a/frontend/src/@redux/actions/series.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
-import { EpisodesApi, SeriesApi } from "../../apis";
-
-export const seriesUpdateWantedById = createAsyncThunk(
- "series/wanted/update/id",
- async (episodeid: number[]) => {
- const response = await EpisodesApi.wantedBy(episodeid);
- return response;
- }
-);
-
-export const seriesUpdateWantedByRange = createAsyncThunk(
- "series/wanted/update/range",
- async (params: Parameter.Range) => {
- const response = await EpisodesApi.wanted(params);
- return response;
- }
-);
-
-export const seriesRemoveWantedById = createAction<number[]>(
- "series/wanted/remove/id"
-);
-
-export const seriesResetWanted = createAction("series/wanted/reset");
-
-export const seriesMarkWantedDirtyById = createAction<number[]>(
- "series/wanted/mark_dirty/episode_id"
-);
-
-export const seriesRemoveById = createAction<number[]>("series/remove");
-
-export const seriesMarkDirtyById = createAction<number[]>(
- "series/mark_dirty/id"
-);
-
-export const seriesUpdateById = createAsyncThunk(
- "series/update/id",
- async (ids: number[]) => {
- const response = await SeriesApi.series(ids);
- return response;
- }
-);
-
-export const seriesUpdateAll = createAsyncThunk(
- "series/update/all",
- async () => {
- const response = await SeriesApi.series();
- return response;
- }
-);
-
-export const seriesUpdateByRange = createAsyncThunk(
- "series/update/range",
- async (params: Parameter.Range) => {
- const response = await SeriesApi.seriesBy(params);
- return response;
- }
-);
-
-export const episodesRemoveById = createAction<number[]>("episodes/remove");
-
-export const episodesMarkDirtyById = createAction<number[]>(
- "episodes/mark_dirty/id"
-);
-
-export const episodeUpdateBySeriesId = createAsyncThunk(
- "episodes/update/series_id",
- async (seriesid: number[]) => {
- const response = await EpisodesApi.bySeriesId(seriesid);
- return response;
- }
-);
-
-export const episodeUpdateById = createAsyncThunk(
- "episodes/update/episodes_id",
- async (episodeid: number[]) => {
- const response = await EpisodesApi.byEpisodeId(episodeid);
- return response;
- }
-);
-
-export const episodesUpdateHistoryByRange = createAsyncThunk(
- "episodes/history/update/range",
- async (param: Parameter.Range) => {
- const response = await EpisodesApi.history(param);
- return response;
- }
-);
-
-export const episodesMarkHistoryDirty = createAction<number[]>(
- "episodes/history/update"
-);
-
-export const episodesResetHistory = createAction("episodes/history/reset");
-
-export const episodesUpdateBlacklist = createAsyncThunk(
- "episodes/blacklist/update",
- async () => {
- const response = await EpisodesApi.blacklist();
- return response;
- }
-);
-
-export const episodesMarkBlacklistDirty = createAction(
- "episodes/blacklist/update"
-);
diff --git a/frontend/src/@redux/actions/site.ts b/frontend/src/@redux/actions/site.ts
deleted file mode 100644
index fc348b942..000000000
--- a/frontend/src/@redux/actions/site.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
-import { BadgesApi } from "../../apis";
-import { waitFor } from "../../utilities";
-import { systemUpdateAllSettings } from "./system";
-
-export const siteBootstrap = createAsyncThunk(
- "site/bootstrap",
- (_: undefined, { dispatch }) => {
- return Promise.all([
- dispatch(systemUpdateAllSettings()),
- dispatch(siteUpdateBadges()),
- ]);
- }
-);
-
-export const siteUpdateInitialization = createAction<string | true>(
- "site/initialization/update"
-);
-
-export const siteRedirectToAuth = createAction("site/redirect_auth");
-
-export const siteAddNotifications = createAction<Server.Notification[]>(
- "site/notifications/add"
-);
-
-export const siteRemoveNotifications = createAction<string>(
- "site/notifications/remove"
-);
-
-export const siteAddProgress =
- createAction<Site.Progress[]>("site/progress/add");
-
-export const siteUpdateProgressCount = createAction<{
- id: string;
- count: number;
-}>("site/progress/update_count");
-
-export const siteRemoveProgress = createAsyncThunk(
- "site/progress/remove",
- async (ids: string[]) => {
- await waitFor(3 * 1000);
- return ids;
- }
-);
-
-export const siteUpdateNotifier = createAction<string>(
- "site/progress/update_notifier"
-);
-
-export const siteChangeSidebarVisibility = createAction<boolean>(
- "site/sidebar/visibility"
-);
-
-export const siteUpdateOffline = createAction<boolean>("site/offline/update");
-
-export const siteUpdateBadges = createAsyncThunk(
- "site/badges/update",
- async () => {
- const response = await BadgesApi.all();
- return response;
- }
-);
diff --git a/frontend/src/@redux/actions/system.ts b/frontend/src/@redux/actions/system.ts
deleted file mode 100644
index ad98dd7f8..000000000
--- a/frontend/src/@redux/actions/system.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
-import { ProvidersApi, SystemApi } from "../../apis";
-
-export const systemUpdateAllSettings = createAsyncThunk(
- "system/update",
- async (_: undefined, { dispatch }) => {
- await Promise.all([
- dispatch(systemUpdateSettings()),
- dispatch(systemUpdateLanguages()),
- dispatch(systemUpdateLanguagesProfiles()),
- ]);
- }
-);
-
-export const systemUpdateLanguages = createAsyncThunk(
- "system/languages/update",
- async () => {
- const response = await SystemApi.languages();
- return response;
- }
-);
-
-export const systemUpdateLanguagesProfiles = createAsyncThunk(
- "system/languages/profile/update",
- async () => {
- const response = await SystemApi.languagesProfileList();
- return response;
- }
-);
-
-export const systemUpdateStatus = createAsyncThunk(
- "system/status/update",
- async () => {
- const response = await SystemApi.status();
- return response;
- }
-);
-
-export const systemUpdateHealth = createAsyncThunk(
- "system/health/update",
- async () => {
- const response = await SystemApi.health();
- return response;
- }
-);
-
-export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
-
-export const systemUpdateTasks = createAsyncThunk(
- "system/tasks/update",
- async () => {
- const response = await SystemApi.tasks();
- return response;
- }
-);
-
-export const systemUpdateLogs = createAsyncThunk(
- "system/logs/update",
- async () => {
- const response = await SystemApi.logs();
- return response;
- }
-);
-
-export const systemUpdateReleases = createAsyncThunk(
- "system/releases/update",
- async () => {
- const response = await SystemApi.releases();
- return response;
- }
-);
-
-export const systemUpdateSettings = createAsyncThunk(
- "system/settings/update",
- async () => {
- const response = await SystemApi.settings();
- return response;
- }
-);
-
-export const providerUpdateList = createAsyncThunk(
- "providers/update",
- async () => {
- const response = await ProvidersApi.providers();
- return response;
- }
-);
diff --git a/frontend/src/@redux/hooks/async.ts b/frontend/src/@redux/hooks/async.ts
deleted file mode 100644
index 8bbd00517..000000000
--- a/frontend/src/@redux/hooks/async.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { AsyncThunk } from "@reduxjs/toolkit";
-import { useEffect } from "react";
-import { log } from "../../utilities/logger";
-import { useReduxAction } from "./base";
-
-export function useAutoUpdate(item: Async.Item<any>, update: () => void) {
- useEffect(() => {
- if (item.state === "uninitialized" || item.state === "dirty") {
- update();
- }
- }, [item.state, update]);
-}
-
-export function useAutoDirtyUpdate(
- item: Async.List<any> | Async.Entity<any>,
- updateAction: AsyncThunk<any, number[], {}>
-) {
- const { state, dirtyEntities } = item;
- const hasDirty = dirtyEntities.length > 0 && state === "dirty";
-
- const update = useReduxAction(updateAction);
-
- useEffect(() => {
- if (hasDirty) {
- log("info", "updating dirty entities...");
- update(dirtyEntities.map(Number));
- }
- }, [hasDirty, dirtyEntities, update]);
-}
diff --git a/frontend/src/@redux/hooks/index.ts b/frontend/src/@redux/hooks/index.ts
index a13627245..c7bbadff8 100644
--- a/frontend/src/@redux/hooks/index.ts
+++ b/frontend/src/@redux/hooks/index.ts
@@ -1,4 +1,39 @@
-export * from "./movies";
-export * from "./series";
-export * from "./site";
-export * from "./system";
+import { useSystemSettings } from "apis/hooks";
+import { useCallback } from "react";
+import { addNotifications } from "../actions";
+import { useReduxAction, useReduxStore } from "./base";
+
+export function useNotification(id: string, timeout: number = 5000) {
+ const add = useReduxAction(addNotifications);
+
+ return useCallback(
+ (msg: Omit<Server.Notification, "id" | "timeout">) => {
+ const notification: Server.Notification = {
+ ...msg,
+ id,
+ timeout,
+ };
+ add([notification]);
+ },
+ [add, timeout, id]
+ );
+}
+
+export function useIsOffline() {
+ return useReduxStore((s) => s.offline);
+}
+
+export function useIsSonarrEnabled() {
+ const { data } = useSystemSettings();
+ return data?.general.use_sonarr ?? true;
+}
+
+export function useIsRadarrEnabled() {
+ const { data } = useSystemSettings();
+ return data?.general.use_radarr ?? true;
+}
+
+export function useShowOnlyDesired() {
+ const { data } = useSystemSettings();
+ return data?.general.embedded_subs_show_desired ?? false;
+}
diff --git a/frontend/src/@redux/hooks/movies.ts b/frontend/src/@redux/hooks/movies.ts
deleted file mode 100644
index 485e93451..000000000
--- a/frontend/src/@redux/hooks/movies.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useCallback, useMemo } from "react";
-import { useEntityItemById, useEntityToList } from "../../utilities";
-import {
- movieUpdateBlacklist,
- movieUpdateById,
- movieUpdateWantedById,
-} from "../actions";
-import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
-import { useReduxAction, useReduxStore } from "./base";
-
-export function useMovieEntities() {
- const entities = useReduxStore((d) => d.movies.movieList);
-
- useAutoDirtyUpdate(entities, movieUpdateById);
-
- return entities;
-}
-
-export function useMovies() {
- const rawMovies = useMovieEntities();
- const content = useEntityToList(rawMovies.content);
- const movies = useMemo<Async.List<Item.Movie>>(() => {
- return {
- ...rawMovies,
- keyName: rawMovies.content.keyName,
- content,
- };
- }, [rawMovies, content]);
- return movies;
-}
-
-export function useMovieBy(id: number) {
- const movies = useMovieEntities();
- const action = useReduxAction(movieUpdateById);
-
- const update = useCallback(() => {
- if (!isNaN(id)) {
- action([id]);
- }
- }, [id, action]);
-
- const movie = useEntityItemById(movies, id.toString());
-
- useAutoUpdate(movie, update);
- return movie;
-}
-
-export function useWantedMovies() {
- const items = useReduxStore((d) => d.movies.wantedMovieList);
-
- useAutoDirtyUpdate(items, movieUpdateWantedById);
- return items;
-}
-
-export function useBlacklistMovies() {
- const update = useReduxAction(movieUpdateBlacklist);
- const items = useReduxStore((d) => d.movies.blacklist);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useMoviesHistory() {
- const items = useReduxStore((s) => s.movies.historyList);
-
- return items;
-}
diff --git a/frontend/src/@redux/hooks/series.ts b/frontend/src/@redux/hooks/series.ts
deleted file mode 100644
index 6892ced83..000000000
--- a/frontend/src/@redux/hooks/series.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useCallback, useEffect, useMemo } from "react";
-import { useEntityItemById, useEntityToList } from "../../utilities";
-import {
- episodesUpdateBlacklist,
- episodeUpdateById,
- episodeUpdateBySeriesId,
- seriesUpdateById,
- seriesUpdateWantedById,
-} from "../actions";
-import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
-import { useReduxAction, useReduxStore } from "./base";
-
-export function useSerieEntities() {
- const items = useReduxStore((d) => d.series.seriesList);
-
- useAutoDirtyUpdate(items, seriesUpdateById);
- return items;
-}
-
-export function useSeries() {
- const rawSeries = useSerieEntities();
- const content = useEntityToList(rawSeries.content);
- const series = useMemo<Async.List<Item.Series>>(() => {
- return {
- ...rawSeries,
- keyName: rawSeries.content.keyName,
- content,
- };
- }, [rawSeries, content]);
- return series;
-}
-
-export function useSerieBy(id: number) {
- const series = useSerieEntities();
- const action = useReduxAction(seriesUpdateById);
- const serie = useEntityItemById(series, String(id));
-
- const update = useCallback(() => {
- if (!isNaN(id)) {
- action([id]);
- }
- }, [id, action]);
-
- useAutoUpdate(serie, update);
- return serie;
-}
-
-export function useEpisodesBy(seriesId: number) {
- const action = useReduxAction(episodeUpdateBySeriesId);
- const update = useCallback(() => {
- if (!isNaN(seriesId)) {
- action([seriesId]);
- }
- }, [action, seriesId]);
-
- const episodes = useReduxStore((d) => d.series.episodeList);
-
- const newContent = useMemo(() => {
- return episodes.content.filter((v) => v.sonarrSeriesId === seriesId);
- }, [seriesId, episodes.content]);
-
- const newList: Async.List<Item.Episode> = useMemo(
- () => ({
- ...episodes,
- content: newContent,
- }),
- [episodes, newContent]
- );
-
- // FIXME
- useEffect(() => {
- update();
- }, [update]);
-
- useAutoDirtyUpdate(episodes, episodeUpdateById);
-
- return newList;
-}
-
-export function useWantedSeries() {
- const items = useReduxStore((d) => d.series.wantedEpisodesList);
-
- useAutoDirtyUpdate(items, seriesUpdateWantedById);
- return items;
-}
-
-export function useBlacklistSeries() {
- const update = useReduxAction(episodesUpdateBlacklist);
- const items = useReduxStore((d) => d.series.blacklist);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useSeriesHistory() {
- const items = useReduxStore((s) => s.series.historyList);
-
- return items;
-}
diff --git a/frontend/src/@redux/hooks/site.ts b/frontend/src/@redux/hooks/site.ts
deleted file mode 100644
index 8d93fc13f..000000000
--- a/frontend/src/@redux/hooks/site.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useCallback } from "react";
-import { useSystemSettings } from ".";
-import { siteAddNotifications } from "../actions";
-import { useReduxAction, useReduxStore } from "./base";
-
-export function useNotification(id: string, timeout: number = 5000) {
- const add = useReduxAction(siteAddNotifications);
-
- return useCallback(
- (msg: Omit<Server.Notification, "id" | "timeout">) => {
- const notification: Server.Notification = {
- ...msg,
- id,
- timeout,
- };
- add([notification]);
- },
- [add, timeout, id]
- );
-}
-
-export function useIsOffline() {
- return useReduxStore((s) => s.site.offline);
-}
-
-export function useIsSonarrEnabled() {
- const settings = useSystemSettings();
- return settings.content?.general.use_sonarr ?? true;
-}
-
-export function useIsRadarrEnabled() {
- const settings = useSystemSettings();
- return settings.content?.general.use_radarr ?? true;
-}
-
-export function useShowOnlyDesired() {
- const settings = useSystemSettings();
- return settings.content?.general.embedded_subs_show_desired ?? false;
-}
diff --git a/frontend/src/@redux/hooks/system.ts b/frontend/src/@redux/hooks/system.ts
deleted file mode 100644
index 1e41a7355..000000000
--- a/frontend/src/@redux/hooks/system.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useMemo } from "react";
-import {
- providerUpdateList,
- systemUpdateHealth,
- systemUpdateLogs,
- systemUpdateReleases,
- systemUpdateStatus,
- systemUpdateTasks,
-} from "../actions";
-import { useAutoUpdate } from "./async";
-import { useReduxAction, useReduxStore } from "./base";
-
-export function useSystemSettings() {
- const items = useReduxStore((s) => s.system.settings);
-
- return items;
-}
-
-export function useSystemLogs() {
- const items = useReduxStore(({ system }) => system.logs);
- const update = useReduxAction(systemUpdateLogs);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useSystemTasks() {
- const items = useReduxStore((s) => s.system.tasks);
- const update = useReduxAction(systemUpdateTasks);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useSystemStatus() {
- const items = useReduxStore((s) => s.system.status);
- const update = useReduxAction(systemUpdateStatus);
-
- useAutoUpdate(items, update);
- return items.content;
-}
-
-export function useSystemHealth() {
- const items = useReduxStore((s) => s.system.health);
- const update = useReduxAction(systemUpdateHealth);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useSystemProviders() {
- const update = useReduxAction(providerUpdateList);
- const items = useReduxStore((d) => d.system.providers);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useSystemReleases() {
- const items = useReduxStore(({ system }) => system.releases);
- const update = useReduxAction(systemUpdateReleases);
-
- useAutoUpdate(items, update);
- return items;
-}
-
-export function useLanguageProfiles() {
- const items = useReduxStore((s) => s.system.languagesProfiles);
-
- return items.content;
-}
-
-export function useProfileBy(id: number | null | undefined) {
- const profiles = useLanguageProfiles();
- return useMemo(
- () => profiles?.find((v) => v.profileId === id),
- [id, profiles]
- );
-}
-
-export function useLanguages() {
- const data = useReduxStore((s) => s.system.languages);
-
- const languages = useMemo<Language.Info[]>(
- () => data.content?.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
- [data.content]
- );
-
- return languages;
-}
-
-export function useEnabledLanguages() {
- const data = useReduxStore((s) => s.system.languages);
-
- const enabled = useMemo<Language.Info[]>(
- () =>
- data.content
- ?.filter((v) => v.enabled)
- .map((v) => ({ code2: v.code2, name: v.name })) ?? [],
- [data.content]
- );
-
- return enabled;
-}
-
-export function useLanguageBy(code?: string) {
- const languages = useLanguages();
- return useMemo(
- () => languages.find((v) => v.code2 === code),
- [languages, code]
- );
-}
-
-// Convert languageprofile items to language
-export function useProfileItemsToLanguages(profile?: Language.Profile) {
- const languages = useLanguages();
-
- return useMemo(
- () =>
- profile?.items.map<Language.Info>(({ language: code, hi, forced }) => {
- const name = languages.find((v) => v.code2 === code)?.name ?? "";
- return {
- hi: hi === "True",
- forced: forced === "True",
- code2: code,
- name,
- };
- }) ?? [],
- [languages, profile?.items]
- );
-}
diff --git a/frontend/src/@redux/reducers/index.ts b/frontend/src/@redux/reducers/index.ts
index e308c130b..a052ae2a7 100644
--- a/frontend/src/@redux/reducers/index.ts
+++ b/frontend/src/@redux/reducers/index.ts
@@ -1,13 +1,110 @@
-import movies from "./movie";
-import series from "./series";
-import site from "./site";
-import system from "./system";
-
-const AllReducers = {
- movies,
- series,
- site,
- system,
+import { createReducer } from "@reduxjs/toolkit";
+import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
+import apis from "../../apis/queries/client";
+import { isProdEnv } from "../../utilities";
+import {
+ addNotifications,
+ removeNotification,
+ setOfflineStatus,
+ setSidebar,
+ setSiteStatus,
+ setUnauthenticated,
+ siteAddProgress,
+ siteRemoveProgress,
+ siteUpdateNotifier,
+ siteUpdateProgressCount,
+} from "../actions";
+
+interface Site {
+ // Initialization state or error message
+ status: Site.Status;
+ offline: boolean;
+ progress: Site.Progress[];
+ notifier: {
+ content: string | null;
+ timestamp: string;
+ };
+ notifications: Server.Notification[];
+ showSidebar: boolean;
+}
+
+const defaultSite: Site = {
+ status: "uninitialized",
+ progress: [],
+ notifier: {
+ content: null,
+ timestamp: String(Date.now()),
+ },
+ notifications: [],
+ showSidebar: false,
+ offline: false,
};
-export default AllReducers;
+const reducer = createReducer(defaultSite, (builder) => {
+ builder
+ .addCase(setUnauthenticated, (state) => {
+ if (!isProdEnv) {
+ apis._resetApi("NEED_AUTH");
+ }
+ state.status = "unauthenticated";
+ })
+ .addCase(setSiteStatus, (state, action) => {
+ state.status = action.payload;
+ });
+
+ builder
+ .addCase(addNotifications, (state, action) => {
+ state.notifications = uniqBy(
+ [...action.payload, ...state.notifications],
+ (v) => v.id
+ );
+ state.notifications = sortBy(state.notifications, (v) => v.id);
+ })
+ .addCase(removeNotification, (state, action) => {
+ remove(state.notifications, (n) => n.id === action.payload);
+ });
+
+ builder
+ .addCase(siteAddProgress, (state, action) => {
+ state.progress = uniqBy(
+ [...action.payload, ...state.progress],
+ (n) => n.id
+ );
+ state.progress = sortBy(state.progress, (v) => v.id);
+ })
+ .addCase(siteRemoveProgress.pending, (state, action) => {
+ // Mark completed
+ intersectionWith(
+ state.progress,
+ action.meta.arg,
+ (l, r) => l.id === r
+ ).forEach((v) => {
+ v.value = v.count + 1;
+ });
+ })
+ .addCase(siteRemoveProgress.fulfilled, (state, action) => {
+ pullAllWith(state.progress, action.payload, (l, r) => l.id === r);
+ })
+ .addCase(siteUpdateProgressCount, (state, action) => {
+ const { id, count } = action.payload;
+ const progress = state.progress.find((v) => v.id === id);
+ if (progress) {
+ progress.count = count;
+ }
+ });
+
+ builder.addCase(siteUpdateNotifier, (state, action) => {
+ state.notifier.content = action.payload;
+ state.notifier.timestamp = String(Date.now());
+ });
+
+ builder
+ .addCase(setSidebar, (state, action) => {
+ state.showSidebar = action.payload;
+ })
+ .addCase(setOfflineStatus, (state, action) => {
+ state.offline = action.payload;
+ });
+});
+
+export default reducer;
diff --git a/frontend/src/@redux/reducers/movie.ts b/frontend/src/@redux/reducers/movie.ts
deleted file mode 100644
index 9ec47abbc..000000000
--- a/frontend/src/@redux/reducers/movie.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { createReducer } from "@reduxjs/toolkit";
-import {
- movieMarkBlacklistDirty,
- movieMarkDirtyById,
- movieMarkHistoryDirty,
- movieMarkWantedDirtyById,
- movieRemoveById,
- movieRemoveWantedById,
- movieResetHistory,
- movieResetWanted,
- movieUpdateAll,
- movieUpdateBlacklist,
- movieUpdateById,
- movieUpdateByRange,
- movieUpdateHistoryByRange,
- movieUpdateWantedById,
- movieUpdateWantedByRange,
-} from "../actions";
-import { AsyncUtility } from "../utils";
-import {
- createAsyncEntityReducer,
- createAsyncItemReducer,
-} from "../utils/factory";
-
-interface Movie {
- movieList: Async.Entity<Item.Movie>;
- wantedMovieList: Async.Entity<Wanted.Movie>;
- historyList: Async.Entity<History.Movie>;
- blacklist: Async.Item<Blacklist.Movie[]>;
-}
-
-const defaultMovie: Movie = {
- movieList: AsyncUtility.getDefaultEntity("radarrId"),
- wantedMovieList: AsyncUtility.getDefaultEntity("radarrId"),
- historyList: AsyncUtility.getDefaultEntity("id"),
- blacklist: AsyncUtility.getDefaultItem(),
-};
-
-const reducer = createReducer(defaultMovie, (builder) => {
- createAsyncEntityReducer(builder, (s) => s.movieList, {
- range: movieUpdateByRange,
- ids: movieUpdateById,
- removeIds: movieRemoveById,
- all: movieUpdateAll,
- dirty: movieMarkDirtyById,
- });
-
- createAsyncEntityReducer(builder, (s) => s.wantedMovieList, {
- range: movieUpdateWantedByRange,
- ids: movieUpdateWantedById,
- removeIds: movieRemoveWantedById,
- dirty: movieMarkWantedDirtyById,
- reset: movieResetWanted,
- });
-
- createAsyncEntityReducer(builder, (s) => s.historyList, {
- range: movieUpdateHistoryByRange,
- dirty: movieMarkHistoryDirty,
- reset: movieResetHistory,
- });
-
- createAsyncItemReducer(builder, (s) => s.blacklist, {
- all: movieUpdateBlacklist,
- dirty: movieMarkBlacklistDirty,
- });
-});
-
-export default reducer;
diff --git a/frontend/src/@redux/reducers/series.ts b/frontend/src/@redux/reducers/series.ts
deleted file mode 100644
index 88266dbd1..000000000
--- a/frontend/src/@redux/reducers/series.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { createReducer } from "@reduxjs/toolkit";
-import {
- episodesMarkBlacklistDirty,
- episodesMarkDirtyById,
- episodesMarkHistoryDirty,
- episodesRemoveById,
- episodesResetHistory,
- episodesUpdateBlacklist,
- episodesUpdateHistoryByRange,
- episodeUpdateById,
- episodeUpdateBySeriesId,
- seriesMarkDirtyById,
- seriesMarkWantedDirtyById,
- seriesRemoveById,
- seriesRemoveWantedById,
- seriesResetWanted,
- seriesUpdateAll,
- seriesUpdateById,
- seriesUpdateByRange,
- seriesUpdateWantedById,
- seriesUpdateWantedByRange,
-} from "../actions";
-import { AsyncUtility, ReducerUtility } from "../utils";
-import {
- createAsyncEntityReducer,
- createAsyncItemReducer,
- createAsyncListReducer,
-} from "../utils/factory";
-
-interface Series {
- seriesList: Async.Entity<Item.Series>;
- wantedEpisodesList: Async.Entity<Wanted.Episode>;
- episodeList: Async.List<Item.Episode>;
- historyList: Async.Entity<History.Episode>;
- blacklist: Async.Item<Blacklist.Episode[]>;
-}
-
-const defaultSeries: Series = {
- seriesList: AsyncUtility.getDefaultEntity("sonarrSeriesId"),
- wantedEpisodesList: AsyncUtility.getDefaultEntity("sonarrEpisodeId"),
- episodeList: AsyncUtility.getDefaultList("sonarrEpisodeId"),
- historyList: AsyncUtility.getDefaultEntity("id"),
- blacklist: AsyncUtility.getDefaultItem(),
-};
-
-const reducer = createReducer(defaultSeries, (builder) => {
- createAsyncEntityReducer(builder, (s) => s.seriesList, {
- range: seriesUpdateByRange,
- ids: seriesUpdateById,
- removeIds: seriesRemoveById,
- all: seriesUpdateAll,
- });
-
- builder.addCase(seriesMarkDirtyById, (state, action) => {
- const series = state.seriesList;
- const dirtyIds = action.payload.map(String);
-
- ReducerUtility.markDirty(series, dirtyIds);
-
- // Update episode list
- const episodes = state.episodeList;
- const dirtyIdsSet = new Set(dirtyIds);
- const dirtyEpisodeIds = episodes.content
- .filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString()))
- .map((v) => String(v.sonarrEpisodeId));
-
- ReducerUtility.markDirty(episodes, dirtyEpisodeIds);
- });
-
- createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, {
- range: seriesUpdateWantedByRange,
- ids: seriesUpdateWantedById,
- removeIds: seriesRemoveWantedById,
- dirty: seriesMarkWantedDirtyById,
- reset: seriesResetWanted,
- });
-
- createAsyncEntityReducer(builder, (s) => s.historyList, {
- range: episodesUpdateHistoryByRange,
- dirty: episodesMarkHistoryDirty,
- reset: episodesResetHistory,
- });
-
- createAsyncItemReducer(builder, (s) => s.blacklist, {
- all: episodesUpdateBlacklist,
- dirty: episodesMarkBlacklistDirty,
- });
-
- createAsyncListReducer(builder, (s) => s.episodeList, {
- ids: episodeUpdateBySeriesId,
- });
-
- createAsyncListReducer(builder, (s) => s.episodeList, {
- ids: episodeUpdateById,
- removeIds: episodesRemoveById,
- dirty: episodesMarkDirtyById,
- });
-});
-
-export default reducer;
diff --git a/frontend/src/@redux/reducers/site.ts b/frontend/src/@redux/reducers/site.ts
deleted file mode 100644
index 21cc0b370..000000000
--- a/frontend/src/@redux/reducers/site.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { createReducer } from "@reduxjs/toolkit";
-import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
-import apis from "../../apis";
-import { isProdEnv } from "../../utilities";
-import {
- siteAddNotifications,
- siteAddProgress,
- siteBootstrap,
- siteChangeSidebarVisibility,
- siteRedirectToAuth,
- siteRemoveNotifications,
- siteRemoveProgress,
- siteUpdateBadges,
- siteUpdateInitialization,
- siteUpdateNotifier,
- siteUpdateOffline,
- siteUpdateProgressCount,
-} from "../actions/site";
-
-interface Site {
- // Initialization state or error message
- initialized: boolean | string;
- offline: boolean;
- auth: boolean;
- progress: Site.Progress[];
- notifier: {
- content: string | null;
- timestamp: string;
- };
- notifications: Server.Notification[];
- showSidebar: boolean;
- badges: Badge;
-}
-
-const defaultSite: Site = {
- initialized: false,
- auth: true,
- progress: [],
- notifier: {
- content: null,
- timestamp: String(Date.now()),
- },
- notifications: [],
- showSidebar: false,
- badges: {
- movies: 0,
- episodes: 0,
- providers: 0,
- status: 0,
- },
- offline: false,
-};
-
-const reducer = createReducer(defaultSite, (builder) => {
- builder
- .addCase(siteBootstrap.fulfilled, (state) => {
- state.initialized = true;
- })
- .addCase(siteBootstrap.rejected, (state) => {
- state.initialized = "An Error Occurred When Initializing Bazarr UI";
- })
- .addCase(siteRedirectToAuth, (state) => {
- if (!isProdEnv) {
- apis._resetApi("NEED_AUTH");
- }
- state.auth = false;
- })
- .addCase(siteUpdateInitialization, (state, action) => {
- state.initialized = action.payload;
- });
-
- builder
- .addCase(siteAddNotifications, (state, action) => {
- state.notifications = uniqBy(
- [...action.payload, ...state.notifications],
- (v) => v.id
- );
- state.notifications = sortBy(state.notifications, (v) => v.id);
- })
- .addCase(siteRemoveNotifications, (state, action) => {
- remove(state.notifications, (n) => n.id === action.payload);
- });
-
- builder
- .addCase(siteAddProgress, (state, action) => {
- state.progress = uniqBy(
- [...action.payload, ...state.progress],
- (n) => n.id
- );
- state.progress = sortBy(state.progress, (v) => v.id);
- })
- .addCase(siteRemoveProgress.pending, (state, action) => {
- // Mark completed
- intersectionWith(
- state.progress,
- action.meta.arg,
- (l, r) => l.id === r
- ).forEach((v) => {
- v.value = v.count + 1;
- });
- })
- .addCase(siteRemoveProgress.fulfilled, (state, action) => {
- pullAllWith(state.progress, action.payload, (l, r) => l.id === r);
- })
- .addCase(siteUpdateProgressCount, (state, action) => {
- const { id, count } = action.payload;
- const progress = state.progress.find((v) => v.id === id);
- if (progress) {
- progress.count = count;
- }
- });
-
- builder.addCase(siteUpdateNotifier, (state, action) => {
- state.notifier.content = action.payload;
- state.notifier.timestamp = String(Date.now());
- });
-
- builder
- .addCase(siteChangeSidebarVisibility, (state, action) => {
- state.showSidebar = action.payload;
- })
- .addCase(siteUpdateOffline, (state, action) => {
- state.offline = action.payload;
- })
- .addCase(siteUpdateBadges.fulfilled, (state, action) => {
- state.badges = action.payload;
- });
-});
-
-export default reducer;
diff --git a/frontend/src/@redux/reducers/system.ts b/frontend/src/@redux/reducers/system.ts
deleted file mode 100644
index 77e60330d..000000000
--- a/frontend/src/@redux/reducers/system.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { createReducer } from "@reduxjs/toolkit";
-import {
- providerUpdateList,
- systemMarkTasksDirty,
- systemUpdateHealth,
- systemUpdateLanguages,
- systemUpdateLanguagesProfiles,
- systemUpdateLogs,
- systemUpdateReleases,
- systemUpdateSettings,
- systemUpdateStatus,
- systemUpdateTasks,
-} from "../actions";
-import { AsyncUtility } from "../utils";
-import { createAsyncItemReducer } from "../utils/factory";
-
-interface System {
- languages: Async.Item<Language.Server[]>;
- languagesProfiles: Async.Item<Language.Profile[]>;
- status: Async.Item<System.Status>;
- health: Async.Item<System.Health[]>;
- tasks: Async.Item<System.Task[]>;
- providers: Async.Item<System.Provider[]>;
- logs: Async.Item<System.Log[]>;
- releases: Async.Item<ReleaseInfo[]>;
- settings: Async.Item<Settings>;
-}
-
-const defaultSystem: System = {
- languages: AsyncUtility.getDefaultItem(),
- languagesProfiles: AsyncUtility.getDefaultItem(),
- status: AsyncUtility.getDefaultItem(),
- health: AsyncUtility.getDefaultItem(),
- tasks: AsyncUtility.getDefaultItem(),
- providers: AsyncUtility.getDefaultItem(),
- logs: AsyncUtility.getDefaultItem(),
- releases: AsyncUtility.getDefaultItem(),
- settings: AsyncUtility.getDefaultItem(),
-};
-
-const reducer = createReducer(defaultSystem, (builder) => {
- createAsyncItemReducer(builder, (s) => s.languages, {
- all: systemUpdateLanguages,
- });
-
- createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
- all: systemUpdateLanguagesProfiles,
- });
- createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
- createAsyncItemReducer(builder, (s) => s.settings, {
- all: systemUpdateSettings,
- });
- createAsyncItemReducer(builder, (s) => s.releases, {
- all: systemUpdateReleases,
- });
- createAsyncItemReducer(builder, (s) => s.logs, {
- all: systemUpdateLogs,
- });
-
- createAsyncItemReducer(builder, (s) => s.health, {
- all: systemUpdateHealth,
- });
-
- createAsyncItemReducer(builder, (s) => s.tasks, {
- all: systemUpdateTasks,
- dirty: systemMarkTasksDirty,
- });
-
- createAsyncItemReducer(builder, (s) => s.providers, {
- all: providerUpdateList,
- });
-});
-
-export default reducer;
diff --git a/frontend/src/@redux/store/index.ts b/frontend/src/@redux/store/index.ts
index dcda82d74..d2e111db5 100644
--- a/frontend/src/@redux/store/index.ts
+++ b/frontend/src/@redux/store/index.ts
@@ -1,5 +1,5 @@
import { configureStore } from "@reduxjs/toolkit";
-import apis from "../../apis";
+import apis from "../../apis/queries/client";
import reducer from "../reducers";
const store = configureStore({
diff --git a/frontend/src/@redux/tests/helper.ts b/frontend/src/@redux/tests/helper.ts
deleted file mode 100644
index 37ca830c2..000000000
--- a/frontend/src/@redux/tests/helper.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { AsyncUtility } from "../utils";
-
-export interface TestType {
- id: number;
- name: string;
-}
-
-export interface Reducer {
- item: Async.Item<TestType>;
- list: Async.List<TestType>;
- entities: Async.Entity<TestType>;
-}
-
-export const defaultState: Reducer = {
- item: AsyncUtility.getDefaultItem(),
- list: AsyncUtility.getDefaultList("id"),
- entities: AsyncUtility.getDefaultEntity("id"),
-};
-
-export const defaultItem: TestType = { id: 0, name: "test" };
-
-export const defaultList: TestType[] = [
- { id: 0, name: "test" },
- { id: 1, name: "test_1" },
- { id: 2, name: "test_2" },
- { id: 3, name: "test_3" },
- { id: 4, name: "test_4" },
- { id: 5, name: "test_5" },
- { id: 6, name: "test_6" },
- { id: 7, name: "test_6" },
-];
diff --git a/frontend/src/@redux/utils/__tests__/async-test.ts b/frontend/src/@redux/utils/__tests__/async-test.ts
deleted file mode 100644
index 631204141..000000000
--- a/frontend/src/@redux/utils/__tests__/async-test.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import {} from "jest";
-import { AsyncUtility } from "..";
-
-interface AsyncTest {
- id: string;
- name: string;
-}
-
-it("Item Init", () => {
- const item = AsyncUtility.getDefaultItem<AsyncTest>();
- expect(item.state).toEqual("uninitialized");
- expect(item.error).toBeNull();
- expect(item.content).toBeNull();
-});
-
-it("List Init", () => {
- const list = AsyncUtility.getDefaultList<AsyncTest>("id");
- expect(list.state).toEqual("uninitialized");
- expect(list.dirtyEntities).toHaveLength(0);
- expect(list.error).toBeNull();
- expect(list.content).toHaveLength(0);
-});
-
-it("Entity Init", () => {
- const entity = AsyncUtility.getDefaultEntity<AsyncTest>("id");
- expect(entity.state).toEqual("uninitialized");
- expect(entity.dirtyEntities).toHaveLength(0);
- expect(entity.error).toBeNull();
- expect(entity.content.ids).toHaveLength(0);
- expect(entity.content.keyName).toBe("id");
- expect(entity.content.entities).toMatchObject({});
-});
diff --git a/frontend/src/@redux/utils/factory.ts b/frontend/src/@redux/utils/factory.ts
deleted file mode 100644
index 5a362d280..000000000
--- a/frontend/src/@redux/utils/factory.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-import {
- ActionCreatorWithoutPayload,
- ActionCreatorWithPayload,
- ActionReducerMapBuilder,
- AsyncThunk,
- Draft,
-} from "@reduxjs/toolkit";
-import {
- difference,
- findIndex,
- isNull,
- isString,
- omit,
- pullAll,
- pullAllWith,
-} from "lodash";
-import { ReducerUtility } from ".";
-import { conditionalLog } from "../../utilities/logger";
-
-interface ActionParam<T, ID = null> {
- range?: AsyncThunk<T, Parameter.Range, {}>;
- all?: AsyncThunk<T, void, {}>;
- ids?: AsyncThunk<T, ID[], {}>;
- removeIds?: ActionCreatorWithPayload<ID[]>;
- reset?: ActionCreatorWithoutPayload;
- dirty?: ID extends null
- ? ActionCreatorWithoutPayload
- : ActionCreatorWithPayload<ID[]>;
-}
-
-export function createAsyncItemReducer<S, T>(
- builder: ActionReducerMapBuilder<S>,
- getItem: (state: Draft<S>) => Draft<Async.Item<T>>,
- actions: Pick<ActionParam<T>, "all" | "dirty">
-) {
- const { all, dirty } = actions;
-
- all &&
- builder
- .addCase(all.pending, (state) => {
- const item = getItem(state);
- item.state = "loading";
- item.error = null;
- })
- .addCase(all.fulfilled, (state, action) => {
- const item = getItem(state);
- item.state = "succeeded";
- item.content = action.payload as Draft<T>;
- })
- .addCase(all.rejected, (state, action) => {
- const item = getItem(state);
- item.state = "failed";
- item.error = action.error.message ?? null;
- });
-
- dirty &&
- builder.addCase(dirty, (state) => {
- const item = getItem(state);
- if (item.state !== "uninitialized") {
- item.state = "dirty";
- }
- });
-}
-
-export function createAsyncListReducer<S, T, ID extends Async.IdType>(
- builder: ActionReducerMapBuilder<S>,
- getList: (state: Draft<S>) => Draft<Async.List<T>>,
- actions: ActionParam<T[], ID>
-) {
- const { ids, removeIds, all, dirty } = actions;
- ids &&
- builder
- .addCase(ids.pending, (state) => {
- const list = getList(state);
- list.state = "loading";
- list.error = null;
- })
- .addCase(ids.fulfilled, (state, action) => {
- const list = getList(state);
-
- const {
- meta: { arg },
- } = action;
-
- const strIds = arg.map(String);
-
- const keyName = list.keyName as keyof T;
-
- action.payload.forEach((v) => {
- const idx = findIndex(list.content, [keyName, v[keyName]]);
- if (idx !== -1) {
- list.content.splice(idx, 1, v as Draft<T>);
- } else {
- list.content.unshift(v as Draft<T>);
- }
- });
-
- ReducerUtility.updateDirty(list, strIds);
- ReducerUtility.updateDidLoaded(list, strIds);
- })
- .addCase(ids.rejected, (state, action) => {
- const list = getList(state);
- list.state = "failed";
- list.error = action.error.message ?? null;
- });
-
- removeIds &&
- builder.addCase(removeIds, (state, action) => {
- const list = getList(state);
- const keyName = list.keyName as keyof T;
-
- const removeIds = action.payload.map(String);
-
- pullAllWith(list.content, removeIds, (lhs, rhs) => {
- return String((lhs as T)[keyName]) === rhs;
- });
-
- ReducerUtility.removeDirty(list, removeIds);
- ReducerUtility.removeDidLoaded(list, removeIds);
- });
-
- all &&
- builder
- .addCase(all.pending, (state) => {
- const list = getList(state);
- list.state = "loading";
- list.error = null;
- })
- .addCase(all.fulfilled, (state, action) => {
- const list = getList(state);
- list.state = "succeeded";
- list.content = action.payload as Draft<T[]>;
- list.dirtyEntities = [];
-
- const ids = action.payload.map((v) =>
- String(v[list.keyName as keyof T])
- );
- ReducerUtility.updateDidLoaded(list, ids);
- })
- .addCase(all.rejected, (state, action) => {
- const list = getList(state);
- list.state = "failed";
- list.error = action.error.message ?? null;
- });
-
- dirty &&
- builder.addCase(dirty, (state, action) => {
- const list = getList(state);
- ReducerUtility.markDirty(list, action.payload.map(String));
- });
-}
-
-export function createAsyncEntityReducer<S, T, ID extends Async.IdType>(
- builder: ActionReducerMapBuilder<S>,
- getEntity: (state: Draft<S>) => Draft<Async.Entity<T>>,
- actions: ActionParam<AsyncDataWrapper<T>, ID>
-) {
- const { all, removeIds, ids, range, dirty, reset } = actions;
-
- const checkSizeUpdate = (entity: Draft<Async.Entity<T>>, newSize: number) => {
- if (entity.content.ids.length !== newSize) {
- // Reset Entity State
- entity.dirtyEntities = [];
- entity.content.ids = Array(newSize).fill(null);
- entity.content.entities = {};
- }
- };
-
- range &&
- builder
- .addCase(range.pending, (state) => {
- const entity = getEntity(state);
- entity.state = "loading";
- entity.error = null;
- })
- .addCase(range.fulfilled, (state, action) => {
- const entity = getEntity(state);
-
- const {
- meta: {
- arg: { start, length },
- },
- payload: { data, total },
- } = action;
-
- const keyName = entity.content.keyName as keyof T;
-
- checkSizeUpdate(entity, total);
-
- data.forEach((v) => {
- const key = String(v[keyName]);
- entity.content.entities[key] = v as Draft<T>;
- });
-
- const idsToUpdate = data.map((v) => String(v[keyName]));
-
- // Remove duplicated ids
- const pulledSize =
- total - pullAll(entity.content.ids, idsToUpdate).length;
- entity.content.ids.push(...Array(pulledSize).fill(null));
-
- entity.content.ids.splice(start, length, ...idsToUpdate);
-
- ReducerUtility.updateDirty(entity, idsToUpdate);
- ReducerUtility.updateDidLoaded(entity, idsToUpdate);
- })
- .addCase(range.rejected, (state, action) => {
- const entity = getEntity(state);
- entity.state = "failed";
- entity.error = action.error.message ?? null;
- });
-
- ids &&
- builder
- .addCase(ids.pending, (state) => {
- const entity = getEntity(state);
- entity.state = "loading";
- entity.error = null;
- })
- .addCase(ids.fulfilled, (state, action) => {
- const entity = getEntity(state);
-
- const {
- meta: { arg },
- payload: { data, total },
- } = action;
-
- const keyName = entity.content.keyName as keyof T;
-
- checkSizeUpdate(entity, total);
-
- const idsToAdd = data.map((v) => String(v[keyName]));
-
- // For new ids, remove null from list and add them
- const newIds = difference(
- idsToAdd,
- entity.content.ids.filter(isString)
- );
- const newSize = entity.content.ids.unshift(...newIds);
- Array(newSize - total)
- .fill(undefined)
- .forEach(() => {
- const idx = entity.content.ids.findIndex(isNull);
- conditionalLog(idx === -1, "Error when deleting ids from entity");
- entity.content.ids.splice(idx, 1);
- });
-
- data.forEach((v) => {
- const key = String(v[keyName]);
- entity.content.entities[key] = v as Draft<T>;
- });
-
- const allIds = arg.map(String);
-
- ReducerUtility.updateDirty(entity, allIds);
- ReducerUtility.updateDidLoaded(entity, allIds);
- })
- .addCase(ids.rejected, (state, action) => {
- const entity = getEntity(state);
- entity.state = "failed";
- entity.error = action.error.message ?? null;
- });
-
- removeIds &&
- builder.addCase(removeIds, (state, action) => {
- const entity = getEntity(state);
- conditionalLog(
- entity.state === "loading",
- "Try to delete async entity when it's now loading"
- );
-
- const idsToDelete = action.payload.map(String);
- pullAll(entity.content.ids, idsToDelete);
- ReducerUtility.removeDirty(entity, idsToDelete);
- ReducerUtility.removeDidLoaded(entity, idsToDelete);
-
- omit(entity.content.entities, idsToDelete);
- });
-
- all &&
- builder
- .addCase(all.pending, (state) => {
- const entity = getEntity(state);
- entity.state = "loading";
- entity.error = null;
- })
- .addCase(all.fulfilled, (state, action) => {
- const entity = getEntity(state);
-
- const {
- payload: { data, total },
- } = action;
-
- conditionalLog(
- data.length !== total,
- "Length of data is mismatch with total length"
- );
-
- const keyName = entity.content.keyName as keyof T;
-
- entity.state = "succeeded";
- entity.dirtyEntities = [];
- entity.content.ids = data.map((v) => String(v[keyName]));
- entity.content.entities = data.reduce<
- Draft<{
- [id: string]: T;
- }>
- >((prev, curr) => {
- const id = String(curr[keyName]);
- prev[id] = curr as Draft<T>;
- return prev;
- }, {});
-
- const allIds = entity.content.ids.filter(isString);
- ReducerUtility.updateDidLoaded(entity, allIds);
- })
- .addCase(all.rejected, (state, action) => {
- const entity = getEntity(state);
- entity.state = "failed";
- entity.error = action.error.message ?? null;
- });
-
- dirty &&
- builder.addCase(dirty, (state, action) => {
- const entity = getEntity(state);
- ReducerUtility.markDirty(entity, action.payload.map(String));
- });
-
- reset &&
- builder.addCase(reset, (state) => {
- const entity = getEntity(state);
- entity.content.entities = {};
- entity.content.ids = [];
- entity.didLoaded = [];
- entity.dirtyEntities = [];
- entity.error = null;
- entity.state = "uninitialized";
- });
-}
diff --git a/frontend/src/@redux/utils/index.ts b/frontend/src/@redux/utils/index.ts
deleted file mode 100644
index 7ffe0b2e7..000000000
--- a/frontend/src/@redux/utils/index.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { Draft } from "@reduxjs/toolkit";
-import { difference, pullAll, uniq } from "lodash";
-
-export namespace AsyncUtility {
- export function getDefaultItem<T>(): Async.Item<T> {
- return {
- state: "uninitialized",
- content: null,
- error: null,
- };
- }
-
- export function getDefaultList<T>(key: keyof T): Async.List<T> {
- return {
- state: "uninitialized",
- keyName: key,
- dirtyEntities: [],
- didLoaded: [],
- content: [],
- error: null,
- };
- }
-
- export function getDefaultEntity<T>(key: keyof T): Async.Entity<T> {
- return {
- state: "uninitialized",
- dirtyEntities: [],
- didLoaded: [],
- content: {
- keyName: key,
- ids: [],
- entities: {},
- },
- error: null,
- };
- }
-}
-
-export namespace ReducerUtility {
- type DirtyType = Draft<Async.Entity<any>> | Draft<Async.List<any>>;
- export function markDirty<T extends DirtyType>(
- entity: T,
- dirtyIds: string[]
- ) {
- if (entity.state !== "uninitialized" && entity.state !== "loading") {
- entity.state = "dirty";
- entity.dirtyEntities.push(...dirtyIds);
- entity.dirtyEntities = uniq(entity.dirtyEntities);
- }
- }
-
- export function updateDirty<T extends DirtyType>(
- entity: T,
- updatedIds: string[]
- ) {
- entity.dirtyEntities = difference(entity.dirtyEntities, updatedIds);
- if (entity.dirtyEntities.length > 0) {
- entity.state = "dirty";
- } else {
- entity.state = "succeeded";
- }
- }
-
- export function removeDirty<T extends DirtyType>(
- entity: T,
- removedIds: string[]
- ) {
- pullAll(entity.dirtyEntities, removedIds);
- if (entity.dirtyEntities.length === 0 && entity.state === "dirty") {
- entity.state = "succeeded";
- }
- }
-
- export function updateDidLoaded<T extends DirtyType>(
- entity: T,
- loadedIds: string[]
- ) {
- entity.didLoaded.push(...loadedIds);
- entity.didLoaded = uniq(entity.didLoaded);
- }
-
- export function removeDidLoaded<T extends DirtyType>(
- entity: T,
- removedIds: string[]
- ) {
- pullAll(entity.didLoaded, removedIds);
- }
-}
diff --git a/frontend/src/@types/api.d.ts b/frontend/src/@types/api.d.ts
index 2d820460a..d7391a9ef 100644
--- a/frontend/src/@types/api.d.ts
+++ b/frontend/src/@types/api.d.ts
@@ -227,7 +227,7 @@ declare namespace History {
series: StatItem[];
};
- type TimeframeOptions = "week" | "month" | "trimester" | "year";
+ type TimeFrameOptions = "week" | "month" | "trimester" | "year";
type ActionOptions = 1 | 2 | 3;
}
diff --git a/frontend/src/@types/async.d.ts b/frontend/src/@types/async.d.ts
deleted file mode 100644
index 8d60accaa..000000000
--- a/frontend/src/@types/async.d.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-declare namespace Async {
- type State = "loading" | "succeeded" | "failed" | "dirty" | "uninitialized";
-
- type IdType = number | string;
-
- type Base<T> = {
- state: State;
- content: T;
- error: string | null;
- };
-
- type List<T> = Base<T[]> & {
- keyName: keyof T;
- dirtyEntities: string[];
- didLoaded: string[];
- };
-
- type Item<T> = Base<T | null>;
-
- type Entity<T> = Base<EntityStruct<T>> & {
- dirtyEntities: string[];
- didLoaded: string[];
- };
-}
diff --git a/frontend/src/@types/function.d.ts b/frontend/src/@types/function.d.ts
new file mode 100644
index 000000000..9efa69169
--- /dev/null
+++ b/frontend/src/@types/function.d.ts
@@ -0,0 +1,3 @@
+type RangeQuery<T> = (
+ param: Parameter.Range
+) => Promise<DataWrapperWithTotal<T>>;
diff --git a/frontend/src/@types/site.d.ts b/frontend/src/@types/site.d.ts
index a2182c8cd..35b45ee73 100644
--- a/frontend/src/@types/site.d.ts
+++ b/frontend/src/@types/site.d.ts
@@ -8,6 +8,7 @@ declare namespace Server {
}
declare namespace Site {
+ type Status = "uninitialized" | "unauthenticated" | "initialized" | "error";
interface Progress {
id: string;
header: string;
diff --git a/frontend/src/@types/utilities.d.ts b/frontend/src/@types/utilities.d.ts
index d59e6e39d..32b8f0421 100644
--- a/frontend/src/@types/utilities.d.ts
+++ b/frontend/src/@types/utilities.d.ts
@@ -29,7 +29,7 @@ interface DataWrapper<T> {
data: T;
}
-interface AsyncDataWrapper<T> {
+interface DataWrapperWithTotal<T> {
data: T[];
total: number;
}
diff --git a/frontend/src/App/Header.tsx b/frontend/src/App/Header.tsx
index 0f91cadae..0349579e3 100644
--- a/frontend/src/App/Header.tsx
+++ b/frontend/src/App/Header.tsx
@@ -5,7 +5,11 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { uniqueId } from "lodash";
+import { setSidebar } from "@redux/actions";
+import { useIsOffline } from "@redux/hooks";
+import { useReduxAction } from "@redux/hooks/base";
+import logo from "@static/logo64.png";
+import { ActionButton, SearchBar } from "components";
import React, { FunctionComponent, useMemo } from "react";
import {
Button,
@@ -17,60 +21,26 @@ import {
Row,
} from "react-bootstrap";
import { Helmet } from "react-helmet";
-import {
- siteChangeSidebarVisibility,
- siteRedirectToAuth,
-} from "../@redux/actions";
-import { useSystemSettings } from "../@redux/hooks";
-import { useReduxAction } from "../@redux/hooks/base";
-import { useIsOffline } from "../@redux/hooks/site";
-import logo from "../@static/logo64.png";
-import { SystemApi } from "../apis";
-import { ActionButton, SearchBar, SearchResult } from "../components";
-import { useGotoHomepage, useIsMobile } from "../utilities";
+import { useGotoHomepage, useIsMobile } from "utilities";
+import { useSystem, useSystemSettings } from "../apis/hooks";
import "./header.scss";
import NotificationCenter from "./Notification";
-async function SearchItem(text: string) {
- const results = await SystemApi.search(text);
-
- return results.map<SearchResult>((v) => {
- let link: string;
- let id: string;
- if (v.sonarrSeriesId) {
- link = `/series/${v.sonarrSeriesId}`;
- id = `series-${v.sonarrSeriesId}`;
- } else if (v.radarrId) {
- link = `/movies/${v.radarrId}`;
- id = `movie-${v.radarrId}`;
- } else {
- link = "";
- id = uniqueId("unknown");
- }
-
- return {
- name: `${v.title} (${v.year})`,
- link,
- id,
- };
- });
-}
-
interface Props {}
const Header: FunctionComponent<Props> = () => {
- const setNeedAuth = useReduxAction(siteRedirectToAuth);
-
- const settings = useSystemSettings();
+ const { data: settings } = useSystemSettings();
- const canLogout = (settings.content?.auth.type ?? "none") === "form";
+ const hasLogout = (settings?.auth.type ?? "none") === "form";
- const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
+ const changeSidebar = useReduxAction(setSidebar);
const offline = useIsOffline();
const isMobile = useIsMobile();
+ const { shutdown, restart, logout } = useSystem();
+
const serverActions = useMemo(
() => (
<Dropdown alignRight>
@@ -80,23 +50,23 @@ const Header: FunctionComponent<Props> = () => {
<Dropdown.Menu>
<Dropdown.Item
onClick={() => {
- SystemApi.restart();
+ restart();
}}
>
Restart
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
- SystemApi.shutdown();
+ shutdown();
}}
>
Shutdown
</Dropdown.Item>
- <Dropdown.Divider hidden={!canLogout}></Dropdown.Divider>
+ <Dropdown.Divider hidden={!hasLogout}></Dropdown.Divider>
<Dropdown.Item
- hidden={!canLogout}
+ hidden={!hasLogout}
onClick={() => {
- SystemApi.logout().then(() => setNeedAuth());
+ logout();
}}
>
Logout
@@ -104,7 +74,7 @@ const Header: FunctionComponent<Props> = () => {
</Dropdown.Menu>
</Dropdown>
),
- [canLogout, setNeedAuth]
+ [hasLogout, logout, restart, shutdown]
);
const goHome = useGotoHomepage();
@@ -133,7 +103,7 @@ const Header: FunctionComponent<Props> = () => {
<Container fluid>
<Row noGutters className="flex-grow-1">
<Col xs={4} sm={6} className="d-flex align-items-center">
- <SearchBar onSearch={SearchItem}></SearchBar>
+ <SearchBar></SearchBar>
</Col>
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
<NotificationCenter></NotificationCenter>
diff --git a/frontend/src/App/Notification.tsx b/frontend/src/App/Notification.tsx
index 73323baa8..16d167fd7 100644
--- a/frontend/src/App/Notification.tsx
+++ b/frontend/src/App/Notification.tsx
@@ -10,6 +10,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
+import { useReduxStore } from "@redux/hooks/base";
import React, {
FunctionComponent,
useCallback,
@@ -26,8 +27,7 @@ import {
Tooltip,
} from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks";
-import { useReduxStore } from "../@redux/hooks/base";
-import { BuildKey, useIsArrayExtended } from "../utilities";
+import { BuildKey, useIsArrayExtended } from "utilities";
import "./notification.scss";
enum State {
@@ -63,7 +63,7 @@ function useHasErrorNotification(notifications: Server.Notification[]) {
}
const NotificationCenter: FunctionComponent = () => {
- const { progress, notifications, notifier } = useReduxStore((s) => s.site);
+ const { progress, notifications, notifier } = useReduxStore((s) => s);
const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false);
diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx
index d88651e7b..d3a8b33e0 100644
--- a/frontend/src/App/index.tsx
+++ b/frontend/src/App/index.tsx
@@ -1,20 +1,18 @@
+import Socketio from "@modules/socketio";
+import { useNotification } from "@redux/hooks";
+import { useReduxStore } from "@redux/hooks/base";
+import { LoadingIndicator, ModalProvider } from "components";
+import Authentication from "pages/Authentication";
+import LaunchError from "pages/LaunchError";
import React, { FunctionComponent, useEffect } from "react";
import { Row } from "react-bootstrap";
-import { Provider } from "react-redux";
import { Route, Switch } from "react-router";
import { BrowserRouter, Redirect } from "react-router-dom";
import { useEffectOnceWhen } from "rooks";
-import Socketio from "../@modules/socketio";
-import { useReduxStore } from "../@redux/hooks/base";
-import { useNotification } from "../@redux/hooks/site";
-import store from "../@redux/store";
-import { LoadingIndicator, ModalProvider } from "../components";
+import { Environment } from "utilities";
+import ErrorBoundary from "../components/ErrorBoundary";
import Router from "../Router";
import Sidebar from "../Sidebar";
-import Auth from "../special-pages/AuthPage";
-import ErrorBoundary from "../special-pages/ErrorBoundary";
-import LaunchError from "../special-pages/LaunchError";
-import { Environment } from "../utilities";
import Header from "./Header";
// Sidebar Toggle
@@ -22,7 +20,7 @@ import Header from "./Header";
interface Props {}
const App: FunctionComponent<Props> = () => {
- const { initialized, auth } = useReduxStore((s) => s.site);
+ const { status } = useReduxStore((s) => s);
const notify = useNotification("has-update", 10 * 1000);
@@ -35,21 +33,20 @@ const App: FunctionComponent<Props> = () => {
// TODO: Restart action
});
}
- }, initialized === true);
+ }, status === "initialized");
- if (!auth) {
+ if (status === "unauthenticated") {
return <Redirect to="/login"></Redirect>;
- }
-
- if (typeof initialized === "boolean" && initialized === false) {
+ } else if (status === "uninitialized") {
return (
<LoadingIndicator>
<span>Please wait</span>
</LoadingIndicator>
);
- } else if (typeof initialized === "string") {
- return <LaunchError>{initialized}</LaunchError>;
+ } else if (status === "error") {
+ return <LaunchError>Cannot Initialize Bazarr</LaunchError>;
}
+
return (
<ErrorBoundary>
<Row noGutters className="header-container">
@@ -74,7 +71,7 @@ const MainRouter: FunctionComponent = () => {
<BrowserRouter basename={Environment.baseUrl}>
<Switch>
<Route exact path="/login">
- <Auth></Auth>
+ <Authentication></Authentication>
</Route>
<Route path="/">
<App></App>
@@ -84,15 +81,4 @@ const MainRouter: FunctionComponent = () => {
);
};
-const Main: FunctionComponent = () => {
- return (
- <Provider store={store}>
- {/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
- {/* <React.StrictMode> */}
- <MainRouter></MainRouter>
- {/* </React.StrictMode> */}
- </Provider>
- );
-};
-
-export default Main;
+export default MainRouter;
diff --git a/frontend/src/Blacklist/Movies/index.tsx b/frontend/src/Blacklist/Movies/index.tsx
deleted file mode 100644
index 7a13cc0ee..000000000
--- a/frontend/src/Blacklist/Movies/index.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent } from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { useBlacklistMovies } from "../../@redux/hooks";
-import { MoviesApi } from "../../apis";
-import { AsyncOverlay, ContentHeader } from "../../components";
-import Table from "./table";
-
-interface Props {}
-
-const BlacklistMoviesView: FunctionComponent<Props> = () => {
- const blacklist = useBlacklistMovies();
- return (
- <AsyncOverlay ctx={blacklist}>
- {({ content }) => (
- <Container fluid>
- <Helmet>
- <title>Movies Blacklist - Bazarr</title>
- </Helmet>
- <ContentHeader>
- <ContentHeader.AsyncButton
- icon={faTrash}
- disabled={content?.length === 0}
- promise={() => MoviesApi.deleteBlacklist(true)}
- >
- Remove All
- </ContentHeader.AsyncButton>
- </ContentHeader>
- <Row>
- <Table blacklist={content ?? []}></Table>
- </Row>
- </Container>
- )}
- </AsyncOverlay>
- );
-};
-
-export default BlacklistMoviesView;
diff --git a/frontend/src/Blacklist/Series/index.tsx b/frontend/src/Blacklist/Series/index.tsx
deleted file mode 100644
index d0262585d..000000000
--- a/frontend/src/Blacklist/Series/index.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent } from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { useBlacklistSeries } from "../../@redux/hooks";
-import { EpisodesApi } from "../../apis";
-import { AsyncOverlay, ContentHeader } from "../../components";
-import Table from "./table";
-
-interface Props {}
-
-const BlacklistSeriesView: FunctionComponent<Props> = () => {
- const blacklist = useBlacklistSeries();
- return (
- <AsyncOverlay ctx={blacklist}>
- {({ content }) => (
- <Container fluid>
- <Helmet>
- <title>Series Blacklist - Bazarr</title>
- </Helmet>
- <ContentHeader>
- <ContentHeader.AsyncButton
- icon={faTrash}
- disabled={content?.length === 0}
- promise={() => EpisodesApi.deleteBlacklist(true)}
- >
- Remove All
- </ContentHeader.AsyncButton>
- </ContentHeader>
- <Row>
- <Table blacklist={content ?? []}></Table>
- </Row>
- </Container>
- )}
- </AsyncOverlay>
- );
-};
-
-export default BlacklistSeriesView;
diff --git a/frontend/src/DisplayItem/generic/BaseItemView/index.tsx b/frontend/src/DisplayItem/generic/BaseItemView/index.tsx
deleted file mode 100644
index 57ad6984c..000000000
--- a/frontend/src/DisplayItem/generic/BaseItemView/index.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
-import { AsyncThunk } from "@reduxjs/toolkit";
-import { uniqBy } from "lodash";
-import React, { useCallback, useMemo, useState } from "react";
-import { Container, Dropdown, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { Column } from "react-table";
-import { useIsAnyTaskRunning } from "../../../@modules/task/hooks";
-import { useLanguageProfiles } from "../../../@redux/hooks";
-import { useAppDispatch } from "../../../@redux/hooks/base";
-import { ContentHeader } from "../../../components";
-import { GetItemId, isNonNullable } from "../../../utilities";
-import Table from "./table";
-
-export interface SharedProps<T extends Item.Base> {
- name: string;
- loader: (params: Parameter.Range) => void;
- columns: Column<T>[];
- modify: (form: FormType.ModifyItem) => Promise<void>;
- state: Async.Entity<T>;
-}
-
-interface Props<T extends Item.Base = Item.Base> extends SharedProps<T> {
- updateAction: AsyncThunk<AsyncDataWrapper<T>, void, {}>;
-}
-
-function BaseItemView<T extends Item.Base>({
- updateAction,
- ...shared
-}: Props<T>) {
- const state = shared.state;
-
- const [pendingEditMode, setPendingEdit] = useState(false);
- const [editMode, setEdit] = useState(false);
-
- const dispatch = useAppDispatch();
- const update = useCallback(() => {
- dispatch(updateAction()).then(() => {
- setPendingEdit((edit) => {
- // Hack to remove all dependencies
- setEdit(edit);
- return edit;
- });
- setDirty([]);
- });
- }, [dispatch, updateAction]);
-
- const [selections, setSelections] = useState<T[]>([]);
- const [dirtyItems, setDirty] = useState<T[]>([]);
-
- const profiles = useLanguageProfiles();
-
- const profileOptions = useMemo<JSX.Element[]>(() => {
- const items: JSX.Element[] = [];
- if (profiles) {
- items.push(
- <Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
- );
- items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
- items.push(
- ...profiles.map((v) => (
- <Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
- {v.name}
- </Dropdown.Item>
- ))
- );
- }
-
- return items;
- }, [profiles]);
-
- const changeProfiles = useCallback(
- (key: Nullable<string>) => {
- const id = key ? parseInt(key) : null;
- const newItems = selections.map((v) => {
- const item = { ...v };
- item.profileId = id;
- return item;
- });
- setDirty((dirty) => {
- return uniqBy([...newItems, ...dirty], GetItemId);
- });
- },
- [selections]
- );
-
- const startEdit = useCallback(() => {
- if (shared.state.content.ids.every(isNonNullable)) {
- setEdit(true);
- } else {
- update();
- }
- setPendingEdit(true);
- }, [shared.state.content.ids, update]);
-
- const endEdit = useCallback(() => {
- setEdit(false);
- setDirty([]);
- setPendingEdit(false);
- setSelections([]);
- }, []);
-
- const save = useCallback(() => {
- const form: FormType.ModifyItem = {
- id: [],
- profileid: [],
- };
- dirtyItems.forEach((v) => {
- const id = GetItemId(v);
- form.id.push(id);
- form.profileid.push(v.profileId);
- });
- return shared.modify(form);
- }, [dirtyItems, shared]);
-
- const hasTask = useIsAnyTaskRunning();
-
- return (
- <Container fluid>
- <Helmet>
- <title>{shared.name} - Bazarr</title>
- </Helmet>
- <ContentHeader scroll={false}>
- {editMode ? (
- <React.Fragment>
- <ContentHeader.Group pos="start">
- <Dropdown onSelect={changeProfiles}>
- <Dropdown.Toggle
- disabled={selections.length === 0}
- variant="light"
- >
- Change Profile
- </Dropdown.Toggle>
- <Dropdown.Menu>{profileOptions}</Dropdown.Menu>
- </Dropdown>
- </ContentHeader.Group>
- <ContentHeader.Group pos="end">
- <ContentHeader.Button icon={faUndo} onClick={endEdit}>
- Cancel
- </ContentHeader.Button>
- <ContentHeader.AsyncButton
- icon={faCheck}
- disabled={dirtyItems.length === 0 || hasTask}
- promise={save}
- onSuccess={endEdit}
- >
- Save
- </ContentHeader.AsyncButton>
- </ContentHeader.Group>
- </React.Fragment>
- ) : (
- <ContentHeader.Button
- updating={pendingEditMode !== editMode}
- disabled={
- (state.content.ids.length === 0 && state.state === "loading") ||
- hasTask
- }
- icon={faList}
- onClick={startEdit}
- >
- Mass Edit
- </ContentHeader.Button>
- )}
- </ContentHeader>
- <Row>
- <Table
- {...shared}
- dirtyItems={dirtyItems}
- editMode={editMode}
- select={setSelections}
- ></Table>
- </Row>
- </Container>
- );
-}
-
-export default BaseItemView;
diff --git a/frontend/src/DisplayItem/generic/BaseItemView/table.tsx b/frontend/src/DisplayItem/generic/BaseItemView/table.tsx
deleted file mode 100644
index 07b7c15fe..000000000
--- a/frontend/src/DisplayItem/generic/BaseItemView/table.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { uniqBy } from "lodash";
-import React, { useCallback, useMemo } from "react";
-import { TableOptions, TableUpdater, useRowSelect } from "react-table";
-import { SharedProps } from ".";
-import {
- AsyncPageTable,
- ItemEditorModal,
- SimpleTable,
- useShowModal,
-} from "../../../components";
-import { TableStyleProps } from "../../../components/tables/BaseTable";
-import { useCustomSelection } from "../../../components/tables/plugins";
-import { GetItemId, useEntityToList } from "../../../utilities";
-
-interface Props<T extends Item.Base> extends SharedProps<T> {
- dirtyItems: readonly T[];
- editMode: boolean;
- select: React.Dispatch<T[]>;
-}
-
-function Table<T extends Item.Base>({
- state,
- dirtyItems,
- modify,
- editMode,
- select,
- columns,
- loader,
- name,
-}: Props<T>) {
- const showModal = useShowModal();
-
- const updateRow = useCallback<TableUpdater<T>>(
- (row, modalKey: string) => {
- showModal(modalKey, row.original);
- },
- [showModal]
- );
-
- const orderList = useEntityToList(state.content);
-
- const data = useMemo(
- () => uniqBy([...dirtyItems, ...orderList], GetItemId),
- [dirtyItems, orderList]
- );
-
- const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
- emptyText: `No ${name} Found`,
- update: updateRow,
- };
-
- return (
- <React.Fragment>
- {editMode ? (
- // TODO: Use PageTable
- <SimpleTable
- {...options}
- columns={columns}
- data={data}
- onSelect={select}
- isSelecting={true}
- plugins={[useRowSelect, useCustomSelection]}
- ></SimpleTable>
- ) : (
- <AsyncPageTable
- {...options}
- columns={columns}
- entity={state}
- loader={loader}
- data={[]}
- ></AsyncPageTable>
- )}
- <ItemEditorModal modalKey="edit" submit={modify}></ItemEditorModal>
- </React.Fragment>
- );
-}
-
-export default Table;
diff --git a/frontend/src/History/Statistics/index.tsx b/frontend/src/History/Statistics/index.tsx
deleted file mode 100644
index a8474ad6b..000000000
--- a/frontend/src/History/Statistics/index.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { merge } from "lodash";
-import React, {
- FunctionComponent,
- useCallback,
- useEffect,
- useState,
-} from "react";
-import { Col, Container } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import {
- Bar,
- BarChart,
- CartesianGrid,
- Legend,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
-import { useDidMount } from "rooks";
-import {
- HistoryApi,
- ProvidersApi,
- SystemApi,
- useAsyncRequest,
-} from "../../apis";
-import {
- AsyncOverlay,
- AsyncSelector,
- ContentHeader,
- LanguageSelector,
- Selector,
-} from "../../components";
-import { actionOptions, timeframeOptions } from "./options";
-
-function converter(item: History.Stat) {
- const movies = item.movies.map((v) => ({
- date: v.date,
- movies: v.count,
- }));
- const series = item.series.map((v) => ({
- date: v.date,
- series: v.count,
- }));
- const result = merge(movies, series);
- return result;
-}
-
-const providerLabel = (item: System.Provider) => item.name;
-
-const SelectorContainer: FunctionComponent = ({ children }) => (
- <Col xs={6} lg={3} className="p-1">
- {children}
- </Col>
-);
-
-const HistoryStats: FunctionComponent = () => {
- const [languages, updateLanguages] = useAsyncRequest(
- SystemApi.languages.bind(SystemApi)
- );
- const [providerList, updateProviderParam] = useAsyncRequest(
- ProvidersApi.providers.bind(ProvidersApi)
- );
-
- const updateProvider = useCallback(
- () => updateProviderParam(true),
- [updateProviderParam]
- );
-
- useDidMount(() => {
- updateLanguages(true);
- });
-
- const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
- const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
- const [lang, setLanguage] = useState<Nullable<Language.Info>>(null);
- const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
-
- const [stats, update] = useAsyncRequest(HistoryApi.stats.bind(HistoryApi));
-
- useEffect(() => {
- update(timeframe, action ?? undefined, provider?.name, lang?.code2);
- }, [timeframe, action, provider?.name, lang?.code2, update]);
-
- return (
- // TODO: Responsive
- <Container fluid className="vh-75">
- <Helmet>
- <title>History Statistics - Bazarr</title>
- </Helmet>
- <AsyncOverlay ctx={stats}>
- {({ content }) => (
- <React.Fragment>
- <ContentHeader scroll={false}>
- <SelectorContainer>
- <Selector
- placeholder="Time..."
- options={timeframeOptions}
- value={timeframe}
- onChange={(v) => setTimeframe(v ?? "month")}
- ></Selector>
- </SelectorContainer>
- <SelectorContainer>
- <Selector
- placeholder="Action..."
- clearable
- options={actionOptions}
- value={action}
- onChange={setAction}
- ></Selector>
- </SelectorContainer>
- <SelectorContainer>
- <AsyncSelector
- placeholder="Provider..."
- clearable
- state={providerList}
- label={providerLabel}
- update={updateProvider}
- onChange={setProvider}
- ></AsyncSelector>
- </SelectorContainer>
- <SelectorContainer>
- <LanguageSelector
- clearable
- options={languages.content ?? []}
- value={lang}
- onChange={setLanguage}
- ></LanguageSelector>
- </SelectorContainer>
- </ContentHeader>
- <ResponsiveContainer height="100%">
- <BarChart data={content ? converter(content) : []}>
- <CartesianGrid strokeDasharray="4 2"></CartesianGrid>
- <XAxis dataKey="date"></XAxis>
- <YAxis allowDecimals={false}></YAxis>
- <Tooltip></Tooltip>
- <Legend verticalAlign="top"></Legend>
- <Bar name="Series" dataKey="series" fill="#2493B6"></Bar>
- <Bar name="Movies" dataKey="movies" fill="#FFC22F"></Bar>
- </BarChart>
- </ResponsiveContainer>
- </React.Fragment>
- )}
- </AsyncOverlay>
- </Container>
- );
-};
-
-export default HistoryStats;
diff --git a/frontend/src/History/generic/index.tsx b/frontend/src/History/generic/index.tsx
deleted file mode 100644
index 0775c6fae..000000000
--- a/frontend/src/History/generic/index.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { capitalize } from "lodash";
-import React from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { Column } from "react-table";
-import { AsyncPageTable } from "../../components";
-
-interface Props<T extends History.Base> {
- type: "movies" | "series";
- state: Readonly<Async.Entity<T>>;
- loader: (param: Parameter.Range) => void;
- columns: Column<T>[];
-}
-
-function HistoryGenericView<T extends History.Base = History.Base>({
- state,
- loader,
- columns,
- type,
-}: Props<T>) {
- const typeName = capitalize(type);
- return (
- <Container fluid>
- <Helmet>
- <title>{typeName} History - Bazarr</title>
- </Helmet>
- <Row>
- <AsyncPageTable
- emptyText={`Nothing Found in ${typeName} History`}
- entity={state}
- loader={loader}
- columns={columns}
- data={[]}
- ></AsyncPageTable>
- </Row>
- </Container>
- );
-}
-
-export default HistoryGenericView;
diff --git a/frontend/src/Navigation/RootRedirect.tsx b/frontend/src/Navigation/RootRedirect.tsx
index eec9a335d..23e71173e 100644
--- a/frontend/src/Navigation/RootRedirect.tsx
+++ b/frontend/src/Navigation/RootRedirect.tsx
@@ -1,6 +1,6 @@
+import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { FunctionComponent } from "react";
import { Redirect } from "react-router-dom";
-import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
const RootRedirect: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
diff --git a/frontend/src/Navigation/index.ts b/frontend/src/Navigation/index.ts
index dbcb4db6a..f1494e42d 100644
--- a/frontend/src/Navigation/index.ts
+++ b/frontend/src/Navigation/index.ts
@@ -7,42 +7,42 @@ import {
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
+import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
+import { useBadges } from "apis/hooks";
+import EmptyPage, { RouterEmptyPath } from "pages/404";
+import BlacklistMoviesView from "pages/Blacklist/Movies";
+import BlacklistSeriesView from "pages/Blacklist/Series";
+import Episodes from "pages/Episodes";
+import MoviesHistoryView from "pages/History/Movies";
+import SeriesHistoryView from "pages/History/Series";
+import HistoryStats from "pages/History/Statistics";
+import MovieView from "pages/Movies";
+import MovieDetail from "pages/Movies/Details";
+import SeriesView from "pages/Series";
+import SettingsGeneralView from "pages/Settings/General";
+import SettingsLanguagesView from "pages/Settings/Languages";
+import SettingsNotificationsView from "pages/Settings/Notifications";
+import SettingsProvidersView from "pages/Settings/Providers";
+import SettingsRadarrView from "pages/Settings/Radarr";
+import SettingsSchedulerView from "pages/Settings/Scheduler";
+import SettingsSonarrView from "pages/Settings/Sonarr";
+import SettingsSubtitlesView from "pages/Settings/Subtitles";
+import SettingsUIView from "pages/Settings/UI";
+import SystemLogsView from "pages/System/Logs";
+import SystemProvidersView from "pages/System/Providers";
+import SystemReleasesView from "pages/System/Releases";
+import SystemStatusView from "pages/System/Status";
+import SystemTasksView from "pages/System/Tasks";
+import WantedMoviesView from "pages/Wanted/Movies";
+import WantedSeriesView from "pages/Wanted/Series";
import { useMemo } from "react";
-import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
-import { useReduxStore } from "../@redux/hooks/base";
-import BlacklistMoviesView from "../Blacklist/Movies";
-import BlacklistSeriesView from "../Blacklist/Series";
-import Episodes from "../DisplayItem/Episodes";
-import MovieDetail from "../DisplayItem/MovieDetail";
-import MovieView from "../DisplayItem/Movies";
-import SeriesView from "../DisplayItem/Series";
-import MoviesHistoryView from "../History/Movies";
-import SeriesHistoryView from "../History/Series";
-import HistoryStats from "../History/Statistics";
-import SettingsGeneralView from "../Settings/General";
-import SettingsLanguagesView from "../Settings/Languages";
-import SettingsNotificationsView from "../Settings/Notifications";
-import SettingsProvidersView from "../Settings/Providers";
-import SettingsRadarrView from "../Settings/Radarr";
-import SettingsSchedulerView from "../Settings/Scheduler";
-import SettingsSonarrView from "../Settings/Sonarr";
-import SettingsSubtitlesView from "../Settings/Subtitles";
-import SettingsUIView from "../Settings/UI";
-import EmptyPage, { RouterEmptyPath } from "../special-pages/404";
-import SystemLogsView from "../System/Logs";
-import SystemProvidersView from "../System/Providers";
-import SystemReleasesView from "../System/Releases";
-import SystemStatusView from "../System/Status";
-import SystemTasksView from "../System/Tasks";
-import WantedMoviesView from "../Wanted/Movies";
-import WantedSeriesView from "../Wanted/Series";
import { Navigation } from "./nav";
import RootRedirect from "./RootRedirect";
export function useNavigationItems() {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
- const { movies, episodes, providers } = useReduxStore((s) => s.site.badges);
+ const { data } = useBadges();
const items = useMemo<Navigation.RouteItem[]>(
() => [
@@ -139,14 +139,14 @@ export function useNavigationItems() {
{
name: "Series",
path: "/series",
- badge: episodes,
+ badge: data?.episodes,
enabled: sonarr,
component: WantedSeriesView,
},
{
name: "Movies",
path: "/movies",
- badge: movies,
+ badge: data?.movies,
enabled: radarr,
component: WantedMoviesView,
},
@@ -222,7 +222,7 @@ export function useNavigationItems() {
{
name: "Providers",
path: "/providers",
- badge: providers,
+ badge: data?.providers,
component: SystemProvidersView,
},
{
@@ -238,7 +238,7 @@ export function useNavigationItems() {
],
},
],
- [episodes, movies, providers, radarr, sonarr]
+ [data, radarr, sonarr]
);
return items;
diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx
index e9295db7f..f8cb506ec 100644
--- a/frontend/src/Router/index.tsx
+++ b/frontend/src/Router/index.tsx
@@ -1,10 +1,10 @@
import { FunctionComponent } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router";
import { useDidMount } from "rooks";
+import { BuildKey, ScrollToTop } from "utilities";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
-import { RouterEmptyPath } from "../special-pages/404";
-import { BuildKey, ScrollToTop } from "../utilities";
+import { RouterEmptyPath } from "../pages/404";
const Router: FunctionComponent = () => {
const navItems = useNavigationItems();
diff --git a/frontend/src/Sidebar/index.tsx b/frontend/src/Sidebar/index.tsx
index 12af3d303..93eb5b105 100644
--- a/frontend/src/Sidebar/index.tsx
+++ b/frontend/src/Sidebar/index.tsx
@@ -1,5 +1,8 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { setSidebar } from "@redux/actions";
+import { useReduxAction, useReduxStore } from "@redux/hooks/base";
+import logo from "@static/logo64.png";
import React, {
createContext,
FunctionComponent,
@@ -16,13 +19,10 @@ import {
ListGroupItem,
} from "react-bootstrap";
import { NavLink, useHistory, useRouteMatch } from "react-router-dom";
-import { siteChangeSidebarVisibility } from "../@redux/actions";
-import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
-import logo from "../@static/logo64.png";
+import { BuildKey } from "utilities";
+import { useGotoHomepage } from "utilities/hooks";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
-import { BuildKey } from "../utilities";
-import { useGotoHomepage } from "../utilities/hooks";
import "./style.scss";
const SelectionContext = createContext<{
@@ -31,9 +31,9 @@ const SelectionContext = createContext<{
}>({ selection: null, select: () => {} });
const Sidebar: FunctionComponent = () => {
- const open = useReduxStore((s) => s.site.showSidebar);
+ const open = useReduxStore((s) => s.showSidebar);
- const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
+ const changeSidebar = useReduxAction(setSidebar);
const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"];
@@ -120,7 +120,7 @@ const SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
[routes]
);
- const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
+ const changeSidebar = useReduxAction(setSidebar);
const { selection, select } = useContext(SelectionContext);
@@ -201,7 +201,7 @@ interface SidebarChildProps {
const SidebarChild: FunctionComponent<
SidebarChildProps & Navigation.RouteWithoutChild
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => {
- const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
+ const changeSidebar = useReduxAction(setSidebar);
const { select } = useContext(SelectionContext);
if (enabled === false || routeOnly === true) {
diff --git a/frontend/src/System/Logs/index.tsx b/frontend/src/System/Logs/index.tsx
deleted file mode 100644
index 9a10e07ae..000000000
--- a/frontend/src/System/Logs/index.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { faDownload, faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent, useCallback, useState } from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { systemUpdateLogs } from "../../@redux/actions";
-import { useSystemLogs } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { SystemApi } from "../../apis";
-import { AsyncOverlay, ContentHeader } from "../../components";
-import { Environment } from "../../utilities";
-import Table from "./table";
-
-interface Props {}
-
-const SystemLogsView: FunctionComponent<Props> = () => {
- const logs = useSystemLogs();
- const update = useReduxAction(systemUpdateLogs);
-
- const [resetting, setReset] = useState(false);
-
- const download = useCallback(() => {
- window.open(`${Environment.baseUrl}/bazarr.log`);
- }, []);
-
- return (
- <AsyncOverlay ctx={logs}>
- {({ content, state }) => (
- <Container fluid>
- <Helmet>
- <title>Logs - Bazarr (System)</title>
- </Helmet>
- <ContentHeader>
- <ContentHeader.Button
- updating={state === "loading"}
- icon={faSync}
- onClick={update}
- >
- Refresh
- </ContentHeader.Button>
- <ContentHeader.Button icon={faDownload} onClick={download}>
- Download
- </ContentHeader.Button>
- <ContentHeader.Button
- updating={resetting}
- icon={faTrash}
- onClick={() => {
- setReset(true);
- SystemApi.deleteLogs().finally(() => {
- setReset(false);
- update();
- });
- }}
- >
- Empty
- </ContentHeader.Button>
- </ContentHeader>
- <Row>
- <Table logs={content ?? []}></Table>
- </Row>
- </Container>
- )}
- </AsyncOverlay>
- );
-};
-
-export default SystemLogsView;
diff --git a/frontend/src/System/Providers/index.tsx b/frontend/src/System/Providers/index.tsx
deleted file mode 100644
index ea3e25fe5..000000000
--- a/frontend/src/System/Providers/index.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent } from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { providerUpdateList } from "../../@redux/actions";
-import { useSystemProviders } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { ProvidersApi } from "../../apis";
-import { AsyncOverlay, ContentHeader } from "../../components";
-import Table from "./table";
-
-interface Props {}
-
-const SystemProvidersView: FunctionComponent<Props> = () => {
- const providers = useSystemProviders();
- const update = useReduxAction(providerUpdateList);
-
- return (
- <AsyncOverlay ctx={providers}>
- {({ content, state }) => (
- <Container fluid>
- <Helmet>
- <title>Providers - Bazarr (System)</title>
- </Helmet>
- <ContentHeader>
- <ContentHeader.Button
- updating={state === "loading"}
- icon={faSync}
- onClick={update}
- >
- Refresh
- </ContentHeader.Button>
- <ContentHeader.AsyncButton
- icon={faTrash}
- promise={() => ProvidersApi.reset()}
- onSuccess={update}
- >
- Reset
- </ContentHeader.AsyncButton>
- </ContentHeader>
- <Row>
- <Table providers={content ?? []}></Table>
- </Row>
- </Container>
- )}
- </AsyncOverlay>
- );
-};
-
-export default SystemProvidersView;
diff --git a/frontend/src/System/Tasks/index.tsx b/frontend/src/System/Tasks/index.tsx
deleted file mode 100644
index 4211e0991..000000000
--- a/frontend/src/System/Tasks/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { faSync } from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent } from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { systemMarkTasksDirty } from "../../@redux/actions";
-import { useSystemTasks } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { AsyncOverlay, ContentHeader } from "../../components";
-import Table from "./table";
-
-interface Props {}
-
-const SystemTasksView: FunctionComponent<Props> = () => {
- const tasks = useSystemTasks();
- const update = useReduxAction(systemMarkTasksDirty);
-
- return (
- <AsyncOverlay ctx={tasks}>
- {({ content, state }) => (
- <Container fluid>
- <Helmet>
- <title>Tasks - Bazarr (System)</title>
- </Helmet>
- <ContentHeader>
- <ContentHeader.Button
- updating={state === "loading"}
- icon={faSync}
- onClick={update}
- >
- Refresh
- </ContentHeader.Button>
- </ContentHeader>
- <Row>
- <Table tasks={content ?? []}></Table>
- </Row>
- </Container>
- )}
- </AsyncOverlay>
- );
-};
-
-export default SystemTasksView;
diff --git a/frontend/src/Wanted/generic/index.tsx b/frontend/src/Wanted/generic/index.tsx
deleted file mode 100644
index 53d842ac2..000000000
--- a/frontend/src/Wanted/generic/index.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { faSearch } from "@fortawesome/free-solid-svg-icons";
-import { capitalize } from "lodash";
-import React from "react";
-import { Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { Column } from "react-table";
-import { dispatchTask } from "../../@modules/task";
-import { useIsGroupTaskRunning } from "../../@modules/task/hooks";
-import { createTask } from "../../@modules/task/utilities";
-import { AsyncPageTable, ContentHeader } from "../../components";
-
-interface Props<T extends Wanted.Base> {
- type: "movies" | "series";
- columns: Column<T>[];
- state: Async.Entity<T>;
- loader: (params: Parameter.Range) => void;
- searchAll: () => Promise<void>;
-}
-
-const TaskGroupName = "Searching wanted subtitles...";
-
-function GenericWantedView<T extends Wanted.Base>({
- type,
- columns,
- state,
- loader,
- searchAll,
-}: Props<T>) {
- const typeName = capitalize(type);
-
- const dataCount = Object.keys(state.content.entities).length;
-
- const hasTask = useIsGroupTaskRunning(TaskGroupName);
-
- return (
- <Container fluid>
- <Helmet>
- <title>Wanted {typeName} - Bazarr</title>
- </Helmet>
- <ContentHeader>
- <ContentHeader.Button
- disabled={dataCount === 0 || hasTask}
- onClick={() => {
- const task = createTask(type, undefined, searchAll);
- dispatchTask(TaskGroupName, [task], "Searching...");
- }}
- icon={faSearch}
- >
- Search All
- </ContentHeader.Button>
- </ContentHeader>
- <Row>
- <AsyncPageTable
- entity={state}
- loader={loader}
- emptyText={`No Missing ${typeName} Subtitles`}
- columns={columns}
- data={[]}
- ></AsyncPageTable>
- </Row>
- </Container>
- );
-}
-
-export default GenericWantedView;
diff --git a/frontend/src/__tests__/render-test.tsx b/frontend/src/__tests__/render-test.tsx
index 81bacc175..5a18956be 100644
--- a/frontend/src/__tests__/render-test.tsx
+++ b/frontend/src/__tests__/render-test.tsx
@@ -1,9 +1,9 @@
+import { Entrance } from "index";
import {} from "jest";
import ReactDOM from "react-dom";
-import App from "../App";
it("renders", () => {
const div = document.createElement("div");
- ReactDOM.render(<App />, div);
+ ReactDOM.render(<Entrance />, div);
ReactDOM.unmountComponentAtNode(div);
});
diff --git a/frontend/src/apis/hooks.ts b/frontend/src/apis/hooks.ts
deleted file mode 100644
index 084efb63f..000000000
--- a/frontend/src/apis/hooks.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useCallback, useRef, useState } from "react";
-
-type Request = (...args: any[]) => Promise<any>;
-type Return<T extends Request> = PromiseType<ReturnType<T>>;
-
-export function useAsyncRequest<F extends Request>(
- request: F
-): [Async.Item<Return<F>>, (...args: Parameters<F>) => void] {
- const [state, setState] = useState<Async.Item<Return<F>>>({
- state: "uninitialized",
- content: null,
- error: null,
- });
-
- const requestRef = useRef(request);
-
- const update = useCallback(
- (...args: Parameters<F>) => {
- setState((s) => ({ ...s, state: "loading" }));
- requestRef
- .current(...args)
- .then((res) =>
- setState({ state: "succeeded", content: res, error: null })
- )
- .catch((error) => setState((s) => ({ ...s, state: "failed", error })));
- },
- [requestRef]
- );
-
- return [state, update];
-}
diff --git a/frontend/src/apis/hooks/episodes.ts b/frontend/src/apis/hooks/episodes.ts
new file mode 100644
index 000000000..d67d2d194
--- /dev/null
+++ b/frontend/src/apis/hooks/episodes.ts
@@ -0,0 +1,115 @@
+import {
+ QueryClient,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "react-query";
+import { usePaginationQuery } from "../queries/hooks";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
+ episodes.forEach((item) => {
+ client.setQueryData(
+ [
+ QueryKeys.Series,
+ item.sonarrSeriesId,
+ QueryKeys.Episodes,
+ item.sonarrEpisodeId,
+ ],
+ item
+ );
+ });
+};
+
+export function useEpisodesByIds(ids: number[]) {
+ const client = useQueryClient();
+ return useQuery(
+ [QueryKeys.Series, QueryKeys.Episodes, ids],
+ () => api.episodes.byEpisodeId(ids),
+ {
+ onSuccess: (data) => {
+ cacheEpisodes(client, data);
+ },
+ }
+ );
+}
+
+export function useEpisodesBySeriesId(id: number) {
+ const client = useQueryClient();
+ return useQuery(
+ [QueryKeys.Series, id, QueryKeys.Episodes, QueryKeys.All],
+ () => api.episodes.bySeriesId([id]),
+ {
+ onSuccess: (data) => {
+ cacheEpisodes(client, data);
+ },
+ }
+ );
+}
+
+export function useEpisodeWantedPagination() {
+ return usePaginationQuery([QueryKeys.Series, QueryKeys.Wanted], (param) =>
+ api.episodes.wanted(param)
+ );
+}
+
+export function useEpisodeBlacklist() {
+ return useQuery(
+ [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+ () => api.episodes.blacklist()
+ );
+}
+
+export function useEpisodeAddBlacklist() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+ (param: {
+ seriesId: number;
+ episodeId: number;
+ form: FormType.AddBlacklist;
+ }) => {
+ const { seriesId, episodeId, form } = param;
+ return api.episodes.addBlacklist(seriesId, episodeId, form);
+ },
+ {
+ onSuccess: (_, { seriesId, episodeId }) => {
+ client.invalidateQueries([QueryKeys.Series, QueryKeys.Blacklist]);
+ client.invalidateQueries([QueryKeys.Series, seriesId]);
+ },
+ }
+ );
+}
+
+export function useEpisodeDeleteBlacklist() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+ (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
+ api.episodes.deleteBlacklist(param.all, param.form),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Series, QueryKeys.Blacklist]);
+ },
+ }
+ );
+}
+
+export function useEpisodeHistoryPagination() {
+ return usePaginationQuery(
+ [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History],
+ (param) => api.episodes.history(param)
+ );
+}
+
+export function useEpisodeHistory(episodeId?: number) {
+ return useQuery(
+ [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History, episodeId],
+ () => {
+ if (episodeId) {
+ return api.episodes.historyBy(episodeId);
+ }
+ }
+ );
+}
diff --git a/frontend/src/apis/hooks/histories.ts b/frontend/src/apis/hooks/histories.ts
new file mode 100644
index 000000000..53a7340ba
--- /dev/null
+++ b/frontend/src/apis/hooks/histories.ts
@@ -0,0 +1,21 @@
+import { useQuery } from "react-query";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+export function useHistoryStats(
+ time: History.TimeFrameOptions,
+ action: History.ActionOptions | null,
+ provider: System.Provider | null,
+ language: Language.Info | null
+) {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.History, { time, action, provider, language }],
+ () =>
+ api.history.stats(
+ time,
+ action ?? undefined,
+ provider?.name,
+ language?.code2
+ )
+ );
+}
diff --git a/frontend/src/apis/hooks/index.ts b/frontend/src/apis/hooks/index.ts
new file mode 100644
index 000000000..34b794592
--- /dev/null
+++ b/frontend/src/apis/hooks/index.ts
@@ -0,0 +1,9 @@
+export * from "./episodes";
+export * from "./histories";
+export * from "./languages";
+export * from "./movies";
+export * from "./providers";
+export * from "./series";
+export * from "./status";
+export * from "./subtitles";
+export * from "./system";
diff --git a/frontend/src/apis/hooks/languages.ts b/frontend/src/apis/hooks/languages.ts
new file mode 100644
index 000000000..d26c46f87
--- /dev/null
+++ b/frontend/src/apis/hooks/languages.ts
@@ -0,0 +1,23 @@
+import { useQuery } from "react-query";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+export function useLanguages(history?: boolean) {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Languages, history ?? false],
+ () => api.system.languages(history),
+ {
+ staleTime: Infinity,
+ }
+ );
+}
+
+export function useLanguageProfiles() {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.LanguagesProfiles],
+ () => api.system.languagesProfileList(),
+ {
+ staleTime: Infinity,
+ }
+ );
+}
diff --git a/frontend/src/apis/hooks/movies.ts b/frontend/src/apis/hooks/movies.ts
new file mode 100644
index 000000000..541e00217
--- /dev/null
+++ b/frontend/src/apis/hooks/movies.ts
@@ -0,0 +1,138 @@
+import {
+ QueryClient,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "react-query";
+import { usePaginationQuery } from "../queries/hooks";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => {
+ movies.forEach((item) => {
+ client.setQueryData([QueryKeys.Movies, item.radarrId], item);
+ });
+};
+
+export function useMoviesByIds(ids: number[]) {
+ const client = useQueryClient();
+ return useQuery([QueryKeys.Movies, ...ids], () => api.movies.movies(ids), {
+ onSuccess: (data) => {
+ cacheMovies(client, data);
+ },
+ });
+}
+
+export function useMovieById(id: number) {
+ return useQuery([QueryKeys.Movies, id], async () => {
+ const response = await api.movies.movies([id]);
+ return response.length > 0 ? response[0] : undefined;
+ });
+}
+
+export function useMovies() {
+ const client = useQueryClient();
+ return useQuery(
+ [QueryKeys.Movies, QueryKeys.All],
+ () => api.movies.movies(),
+ {
+ enabled: false,
+ onSuccess: (data) => {
+ cacheMovies(client, data);
+ },
+ }
+ );
+}
+
+export function useMoviesPagination() {
+ return usePaginationQuery([QueryKeys.Movies], (param) =>
+ api.movies.moviesBy(param)
+ );
+}
+
+export function useMovieModification() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Movies],
+ (form: FormType.ModifyItem) => api.movies.modify(form),
+ {
+ onSuccess: (_, form) => {
+ form.id.forEach((v) => {
+ client.invalidateQueries([QueryKeys.Movies, v]);
+ });
+ // TODO: query less
+ client.invalidateQueries([QueryKeys.Movies]);
+ },
+ }
+ );
+}
+
+export function useMovieAction() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Actions, QueryKeys.Movies],
+ (form: FormType.MoviesAction) => api.movies.action(form),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.Movies]);
+ },
+ }
+ );
+}
+
+export function useMovieWantedPagination() {
+ return usePaginationQuery([QueryKeys.Movies, QueryKeys.Wanted], (param) =>
+ api.movies.wanted(param)
+ );
+}
+
+export function useMovieBlacklist() {
+ return useQuery([QueryKeys.Movies, QueryKeys.Blacklist], () =>
+ api.movies.blacklist()
+ );
+}
+
+export function useMovieAddBlacklist() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Movies, QueryKeys.Blacklist],
+ (param: { id: number; form: FormType.AddBlacklist }) => {
+ const { id, form } = param;
+ return api.movies.addBlacklist(id, form);
+ },
+ {
+ onSuccess: (_, { id }) => {
+ client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
+ client.invalidateQueries([QueryKeys.Movies, id]);
+ },
+ }
+ );
+}
+
+export function useMovieDeleteBlacklist() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Movies, QueryKeys.Blacklist],
+ (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
+ api.movies.deleteBlacklist(param.all, param.form),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
+ },
+ }
+ );
+}
+
+export function useMovieHistoryPagination() {
+ return usePaginationQuery([QueryKeys.Movies, QueryKeys.History], (param) =>
+ api.movies.history(param)
+ );
+}
+
+export function useMovieHistory(radarrId?: number) {
+ return useQuery([QueryKeys.Movies, QueryKeys.History, radarrId], () => {
+ if (radarrId) {
+ return api.movies.historyBy(radarrId);
+ }
+ });
+}
diff --git a/frontend/src/apis/hooks/providers.ts b/frontend/src/apis/hooks/providers.ts
new file mode 100644
index 000000000..f1daf9f37
--- /dev/null
+++ b/frontend/src/apis/hooks/providers.ts
@@ -0,0 +1,99 @@
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+export function useSystemProviders(history?: boolean) {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Providers, history ?? false],
+ () => api.providers.providers(history)
+ );
+}
+
+export function useMoviesProvider(radarrId?: number) {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Providers, QueryKeys.Movies, radarrId],
+ () => {
+ if (radarrId) {
+ return api.providers.movies(radarrId);
+ }
+ },
+ {
+ staleTime: Infinity,
+ }
+ );
+}
+
+export function useEpisodesProvider(episodeId?: number) {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Providers, QueryKeys.Episodes, episodeId],
+ () => {
+ if (episodeId) {
+ return api.providers.episodes(episodeId);
+ }
+ },
+ {
+ staleTime: Infinity,
+ }
+ );
+}
+
+export function useResetProvider() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.System, QueryKeys.Providers],
+ () => api.providers.reset(),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.System, QueryKeys.Providers]);
+ },
+ }
+ );
+}
+
+export function useDownloadEpisodeSubtitles() {
+ const client = useQueryClient();
+
+ return useMutation(
+ [
+ QueryKeys.System,
+ QueryKeys.Providers,
+ QueryKeys.Subtitles,
+ QueryKeys.Episodes,
+ ],
+ (param: {
+ seriesId: number;
+ episodeId: number;
+ form: FormType.ManualDownload;
+ }) =>
+ api.providers.downloadEpisodeSubtitle(
+ param.seriesId,
+ param.episodeId,
+ param.form
+ ),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Series, param.seriesId]);
+ },
+ }
+ );
+}
+
+export function useDownloadMovieSubtitles() {
+ const client = useQueryClient();
+
+ return useMutation(
+ [
+ QueryKeys.System,
+ QueryKeys.Providers,
+ QueryKeys.Subtitles,
+ QueryKeys.Movies,
+ ],
+ (param: { radarrId: number; form: FormType.ManualDownload }) =>
+ api.providers.downloadMovieSubtitle(param.radarrId, param.form),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
+ },
+ }
+ );
+}
diff --git a/frontend/src/apis/hooks/series.ts b/frontend/src/apis/hooks/series.ts
new file mode 100644
index 000000000..4de9f5c1b
--- /dev/null
+++ b/frontend/src/apis/hooks/series.ts
@@ -0,0 +1,80 @@
+import {
+ QueryClient,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "react-query";
+import { usePaginationQuery } from "../queries/hooks";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+function cacheSeries(client: QueryClient, series: Item.Series[]) {
+ series.forEach((item) => {
+ client.setQueryData([QueryKeys.Series, item.sonarrSeriesId], item);
+ });
+}
+
+export function useSeriesByIds(ids: number[]) {
+ const client = useQueryClient();
+ return useQuery([QueryKeys.Series, ...ids], () => api.series.series(ids), {
+ onSuccess: (data) => {
+ cacheSeries(client, data);
+ },
+ });
+}
+
+export function useSeriesById(id: number) {
+ return useQuery([QueryKeys.Series, id], async () => {
+ const response = await api.series.series([id]);
+ return response.length > 0 ? response[0] : undefined;
+ });
+}
+
+export function useSeries() {
+ const client = useQueryClient();
+ return useQuery(
+ [QueryKeys.Series, QueryKeys.All],
+ () => api.series.series(),
+ {
+ enabled: false,
+ onSuccess: (data) => {
+ cacheSeries(client, data);
+ },
+ }
+ );
+}
+
+export function useSeriesPagination() {
+ return usePaginationQuery([QueryKeys.Series], (param) =>
+ api.series.seriesBy(param)
+ );
+}
+
+export function useSeriesModification() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Series],
+ (form: FormType.ModifyItem) => api.series.modify(form),
+ {
+ onSuccess: (_, form) => {
+ form.id.forEach((v) => {
+ client.invalidateQueries([QueryKeys.Series, v]);
+ });
+ client.invalidateQueries([QueryKeys.Series]);
+ },
+ }
+ );
+}
+
+export function useSeriesAction() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.Actions, QueryKeys.Series],
+ (form: FormType.SeriesAction) => api.series.action(form),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.Series]);
+ },
+ }
+ );
+}
diff --git a/frontend/src/apis/hooks/status.ts b/frontend/src/apis/hooks/status.ts
new file mode 100644
index 000000000..46a73cfda
--- /dev/null
+++ b/frontend/src/apis/hooks/status.ts
@@ -0,0 +1,18 @@
+import { useIsMutating } from "react-query";
+import { QueryKeys } from "../queries/keys";
+
+export function useIsAnyActionRunning() {
+ return useIsMutating([QueryKeys.Actions]) > 0;
+}
+
+export function useIsMovieActionRunning() {
+ return useIsMutating([QueryKeys.Actions, QueryKeys.Movies]) > 0;
+}
+
+export function useIsSeriesActionRunning() {
+ return useIsMutating([QueryKeys.Actions, QueryKeys.Series]) > 0;
+}
+
+export function useIsAnyMutationRunning() {
+ return useIsMutating() > 0;
+}
diff --git a/frontend/src/apis/hooks/subtitles.ts b/frontend/src/apis/hooks/subtitles.ts
new file mode 100644
index 000000000..5080daeb7
--- /dev/null
+++ b/frontend/src/apis/hooks/subtitles.ts
@@ -0,0 +1,119 @@
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+export function useSubtitleAction() {
+ const client = useQueryClient();
+ interface Param {
+ action: string;
+ form: FormType.ModifySubtitle;
+ }
+ return useMutation(
+ [QueryKeys.Subtitles],
+ (param: Param) => api.subtitles.modify(param.action, param.form),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.History]);
+ },
+ }
+ );
+}
+
+export function useEpisodeSubtitleModification() {
+ const client = useQueryClient();
+
+ interface Param<T> {
+ seriesId: number;
+ episodeId: number;
+ form: T;
+ }
+
+ const download = useMutation(
+ [QueryKeys.Subtitles, QueryKeys.Episodes],
+ (param: Param<FormType.Subtitle>) =>
+ api.episodes.downloadSubtitles(
+ param.seriesId,
+ param.episodeId,
+ param.form
+ ),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Series, param.seriesId]);
+ },
+ }
+ );
+
+ const remove = useMutation(
+ [QueryKeys.Subtitles, QueryKeys.Episodes],
+ (param: Param<FormType.DeleteSubtitle>) =>
+ api.episodes.deleteSubtitles(param.seriesId, param.episodeId, param.form),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Series, param.seriesId]);
+ },
+ }
+ );
+
+ const upload = useMutation(
+ [QueryKeys.Subtitles, QueryKeys.Episodes],
+ (param: Param<FormType.UploadSubtitle>) =>
+ api.episodes.uploadSubtitles(param.seriesId, param.episodeId, param.form),
+ {
+ onSuccess: (_, { seriesId }) => {
+ client.invalidateQueries([QueryKeys.Series, seriesId]);
+ },
+ }
+ );
+
+ return { download, remove, upload };
+}
+
+export function useMovieSubtitleModification() {
+ const client = useQueryClient();
+
+ interface Param<T> {
+ radarrId: number;
+ form: T;
+ }
+
+ const download = useMutation(
+ [QueryKeys.Subtitles, QueryKeys.Movies],
+ (param: Param<FormType.Subtitle>) =>
+ api.movies.downloadSubtitles(param.radarrId, param.form),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
+ },
+ }
+ );
+
+ const remove = useMutation(
+ [QueryKeys.Subtitles, QueryKeys.Movies],
+ (param: Param<FormType.DeleteSubtitle>) =>
+ api.movies.deleteSubtitles(param.radarrId, param.form),
+ {
+ onSuccess: (_, param) => {
+ client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
+ },
+ }
+ );
+
+ const upload = useMutation(
+ [QueryKeys.Subtitles, QueryKeys.Movies],
+ (param: Param<FormType.UploadSubtitle>) =>
+ api.movies.uploadSubtitles(param.radarrId, param.form),
+ {
+ onSuccess: (_, { radarrId }) => {
+ client.invalidateQueries([QueryKeys.Movies, radarrId]);
+ },
+ }
+ );
+
+ return { download, remove, upload };
+}
+
+export function useSubtitleInfos(names: string[]) {
+ return useQuery([QueryKeys.Subtitles, QueryKeys.Infos, names], () =>
+ api.subtitles.info(names)
+ );
+}
diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts
new file mode 100644
index 000000000..f096806b8
--- /dev/null
+++ b/frontend/src/apis/hooks/system.ts
@@ -0,0 +1,188 @@
+import { useMemo } from "react";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { setUnauthenticated } from "../../@redux/actions";
+import store from "../../@redux/store";
+import { QueryKeys } from "../queries/keys";
+import api from "../raw";
+
+export function useBadges() {
+ return useQuery([QueryKeys.System, QueryKeys.Badges], () => api.badges.all());
+}
+
+export function useFileSystem(
+ type: "bazarr" | "sonarr" | "radarr",
+ path: string,
+ enabled: boolean
+) {
+ return useQuery(
+ [QueryKeys.FileSystem, type, path],
+ () => {
+ if (type === "bazarr") {
+ return api.files.bazarr(path);
+ } else if (type === "radarr") {
+ return api.files.radarr(path);
+ } else if (type === "sonarr") {
+ return api.files.sonarr(path);
+ }
+ },
+ {
+ enabled,
+ }
+ );
+}
+
+export function useSystemSettings() {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Settings],
+ () => api.system.settings(),
+ {
+ staleTime: Infinity,
+ }
+ );
+}
+
+export function useSettingsMutation() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.System, QueryKeys.Settings],
+ (data: LooseObject) => api.system.updateSettings(data),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.System]);
+ },
+ }
+ );
+}
+
+export function useServerSearch(query: string, enabled: boolean) {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Search, query],
+ () => api.system.search(query),
+ {
+ enabled,
+ }
+ );
+}
+
+export function useSystemLogs() {
+ return useQuery([QueryKeys.System, QueryKeys.Logs], () => api.system.logs(), {
+ refetchOnWindowFocus: "always",
+ refetchInterval: 1000 * 60,
+ staleTime: 1000,
+ });
+}
+
+export function useDeleteLogs() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.System, QueryKeys.Logs],
+ () => api.system.deleteLogs(),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.System, QueryKeys.Logs]);
+ },
+ }
+ );
+}
+
+export function useSystemTasks() {
+ return useQuery(
+ [QueryKeys.System, QueryKeys.Tasks],
+ () => api.system.tasks(),
+ {
+ refetchOnWindowFocus: "always",
+ refetchInterval: 1000 * 60,
+ staleTime: 1000 * 10,
+ }
+ );
+}
+
+export function useRunTask() {
+ const client = useQueryClient();
+ return useMutation(
+ [QueryKeys.System, QueryKeys.Tasks],
+ (id: string) => api.system.runTask(id),
+ {
+ onSuccess: () => {
+ client.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
+ },
+ }
+ );
+}
+
+export function useSystemStatus() {
+ return useQuery([QueryKeys.System, "status"], () => api.system.status());
+}
+
+export function useSystemHealth() {
+ return useQuery([QueryKeys.System, "health"], () => api.system.health());
+}
+
+export function useSystemReleases() {
+ return useQuery([QueryKeys.System, "releases"], () => api.system.releases());
+}
+
+export function useSystem() {
+ const client = useQueryClient();
+ const { mutate: logout, isLoading: isLoggingOut } = useMutation(
+ [QueryKeys.System, QueryKeys.Actions],
+ () => api.system.logout(),
+ {
+ onSuccess: () => {
+ store.dispatch(setUnauthenticated());
+ client.clear();
+ },
+ }
+ );
+
+ const { mutate: login, isLoading: isLoggingIn } = useMutation(
+ [QueryKeys.System, QueryKeys.Actions],
+ (param: { username: string; password: string }) =>
+ api.system.login(param.username, param.password),
+ {
+ onSuccess: () => {
+ window.location.reload();
+ },
+ }
+ );
+
+ const { mutate: shutdown, isLoading: isShuttingDown } = useMutation(
+ [QueryKeys.System, QueryKeys.Actions],
+ () => api.system.shutdown(),
+ {
+ onSuccess: () => {
+ client.clear();
+ },
+ }
+ );
+
+ const { mutate: restart, isLoading: isRestarting } = useMutation(
+ [QueryKeys.System, QueryKeys.Actions],
+ () => api.system.restart(),
+ {
+ onSuccess: () => {
+ client.clear();
+ },
+ }
+ );
+
+ return useMemo(
+ () => ({
+ logout,
+ shutdown,
+ restart,
+ login,
+ isWorking: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
+ }),
+ [
+ isLoggingIn,
+ isLoggingOut,
+ isRestarting,
+ isShuttingDown,
+ login,
+ logout,
+ restart,
+ shutdown,
+ ]
+ );
+}
diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/queries/client.ts
index 3efc0aba1..2263874bc 100644
--- a/frontend/src/apis/index.ts
+++ b/frontend/src/apis/queries/client.ts
@@ -1,8 +1,8 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
-import { siteRedirectToAuth } from "../@redux/actions";
-import { AppDispatch } from "../@redux/store";
-import { Environment, isProdEnv } from "../utilities";
-class Api {
+import { setUnauthenticated } from "../../@redux/actions";
+import { AppDispatch } from "../../@redux/store";
+import { Environment, isProdEnv } from "../../utilities";
+class BazarrClient {
axios!: AxiosInstance;
source!: CancelTokenSource;
dispatch!: AppDispatch;
@@ -57,7 +57,7 @@ class Api {
handleError(code: number) {
switch (code) {
case 401:
- this.dispatch(siteRedirectToAuth());
+ this.dispatch(setUnauthenticated());
break;
case 500:
break;
@@ -67,15 +67,4 @@ class Api {
}
}
-export default new Api();
-export { default as BadgesApi } from "./badges";
-export { default as EpisodesApi } from "./episodes";
-export { default as FilesApi } from "./files";
-export { default as HistoryApi } from "./history";
-export * from "./hooks";
-export { default as MoviesApi } from "./movies";
-export { default as ProvidersApi } from "./providers";
-export { default as SeriesApi } from "./series";
-export { default as SubtitlesApi } from "./subtitles";
-export { default as SystemApi } from "./system";
-export { default as UtilsApi } from "./utils";
+export default new BazarrClient();
diff --git a/frontend/src/apis/queries/hooks.ts b/frontend/src/apis/queries/hooks.ts
new file mode 100644
index 000000000..b8cc52c9c
--- /dev/null
+++ b/frontend/src/apis/queries/hooks.ts
@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ QueryKey,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "react-query";
+import { GetItemId } from "utilities";
+import { usePageSize } from "utilities/storage";
+import { QueryKeys } from "./keys";
+
+export type UsePaginationQueryResult<T extends object> = UseQueryResult<
+ DataWrapperWithTotal<T>
+> & {
+ controls: {
+ previousPage: () => void;
+ nextPage: () => void;
+ gotoPage: (index: number) => void;
+ };
+ paginationStatus: {
+ totalCount: number;
+ pageSize: number;
+ pageCount: number;
+ page: number;
+ canPrevious: boolean;
+ canNext: boolean;
+ };
+};
+
+export function usePaginationQuery<
+ TObject extends object = object,
+ TQueryKey extends QueryKey = QueryKey
+>(
+ queryKey: TQueryKey,
+ queryFn: RangeQuery<TObject>
+): UsePaginationQueryResult<TObject> {
+ const client = useQueryClient();
+
+ const [page, setIndex] = useState(0);
+ const [pageSize] = usePageSize();
+
+ const start = page * pageSize;
+
+ const results = useQuery(
+ [...queryKey, QueryKeys.Range, { start, size: pageSize }],
+ () => {
+ const param: Parameter.Range = {
+ start,
+ length: pageSize,
+ };
+ return queryFn(param);
+ },
+ {
+ onSuccess: ({ data }) => {
+ data.forEach((item) => {
+ const id = GetItemId(item);
+ if (id) {
+ client.setQueryData([...queryKey, id], item);
+ }
+ });
+ },
+ }
+ );
+
+ const { data } = results;
+
+ const totalCount = data?.total ?? 0;
+ const pageCount = Math.ceil(totalCount / pageSize);
+
+ const previousPage = useCallback(() => {
+ setIndex((index) => Math.max(0, index - 1));
+ }, []);
+
+ const nextPage = useCallback(() => {
+ if (pageCount > 0) {
+ setIndex((index) => Math.min(pageCount - 1, index + 1));
+ }
+ }, [pageCount]);
+
+ const gotoPage = useCallback(
+ (idx: number) => {
+ if (idx >= 0 && idx < pageCount) {
+ setIndex(idx);
+ }
+ },
+ [pageCount]
+ );
+
+ // Reset page index if we out of bound
+ useEffect(() => {
+ if (pageCount === 0) return;
+
+ if (page >= pageCount) {
+ setIndex(pageCount - 1);
+ } else if (page < 0) {
+ setIndex(0);
+ }
+ }, [page, pageCount]);
+
+ return {
+ ...results,
+ paginationStatus: {
+ totalCount,
+ pageCount,
+ pageSize,
+ page,
+ canPrevious: page > 0,
+ canNext: page < pageCount - 1,
+ },
+ controls: {
+ gotoPage,
+ previousPage,
+ nextPage,
+ },
+ };
+}
diff --git a/frontend/src/apis/queries/index.ts b/frontend/src/apis/queries/index.ts
new file mode 100644
index 000000000..a1a17ffd9
--- /dev/null
+++ b/frontend/src/apis/queries/index.ts
@@ -0,0 +1,14 @@
+import { QueryClient } from "react-query";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: false,
+ staleTime: 1000 * 60,
+ keepPreviousData: true,
+ },
+ },
+});
+
+export default queryClient;
diff --git a/frontend/src/apis/queries/keys.ts b/frontend/src/apis/queries/keys.ts
new file mode 100644
index 000000000..cfdd44133
--- /dev/null
+++ b/frontend/src/apis/queries/keys.ts
@@ -0,0 +1,23 @@
+export enum QueryKeys {
+ Movies = "movies",
+ Episodes = "episodes",
+ Series = "series",
+ Badges = "badges",
+ FileSystem = "file-system",
+ System = "system",
+ Settings = "settings",
+ Subtitles = "subtitles",
+ Providers = "providers",
+ Languages = "languages",
+ LanguagesProfiles = "languages-profiles",
+ Blacklist = "blacklist",
+ Search = "search",
+ Actions = "actions",
+ Tasks = "tasks",
+ Logs = "logs",
+ Infos = "infos",
+ History = "history",
+ Wanted = "wanted",
+ Range = "range",
+ All = "all",
+}
diff --git a/frontend/src/apis/badges.ts b/frontend/src/apis/raw/badges.ts
index 0021dede6..0021dede6 100644
--- a/frontend/src/apis/badges.ts
+++ b/frontend/src/apis/raw/badges.ts
diff --git a/frontend/src/apis/base.ts b/frontend/src/apis/raw/base.ts
index 3a0ab4bb9..2c514adf9 100644
--- a/frontend/src/apis/base.ts
+++ b/frontend/src/apis/raw/base.ts
@@ -1,5 +1,5 @@
import { AxiosResponse } from "axios";
-import apis from ".";
+import client from "../queries/client";
class BaseApi {
prefix: string;
@@ -31,7 +31,7 @@ class BaseApi {
}
protected async get<T = unknown>(path: string, params?: any) {
- const response = await apis.axios.get<T>(this.prefix + path, { params });
+ const response = await client.axios.get<T>(this.prefix + path, { params });
return response.data;
}
@@ -41,7 +41,7 @@ class BaseApi {
params?: any
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
- return apis.axios.post(this.prefix + path, form, { params });
+ return client.axios.post(this.prefix + path, form, { params });
}
protected patch<T = void>(
@@ -50,7 +50,7 @@ class BaseApi {
params?: any
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
- return apis.axios.patch(this.prefix + path, form, { params });
+ return client.axios.patch(this.prefix + path, form, { params });
}
protected delete<T = void>(
@@ -59,7 +59,7 @@ class BaseApi {
params?: any
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
- return apis.axios.delete(this.prefix + path, { params, data: form });
+ return client.axios.delete(this.prefix + path, { params, data: form });
}
}
diff --git a/frontend/src/apis/episodes.ts b/frontend/src/apis/raw/episodes.ts
index 345954b32..2075fb8e6 100644
--- a/frontend/src/apis/episodes.ts
+++ b/frontend/src/apis/raw/episodes.ts
@@ -20,7 +20,7 @@ class EpisodeApi extends BaseApi {
}
async wanted(params: Parameter.Range) {
- const response = await this.get<AsyncDataWrapper<Wanted.Episode>>(
+ const response = await this.get<DataWrapperWithTotal<Wanted.Episode>>(
"/wanted",
params
);
@@ -28,7 +28,7 @@ class EpisodeApi extends BaseApi {
}
async wantedBy(episodeid: number[]) {
- const response = await this.get<AsyncDataWrapper<Wanted.Episode>>(
+ const response = await this.get<DataWrapperWithTotal<Wanted.Episode>>(
"/wanted",
{ episodeid }
);
@@ -36,7 +36,7 @@ class EpisodeApi extends BaseApi {
}
async history(params: Parameter.Range) {
- const response = await this.get<AsyncDataWrapper<History.Episode>>(
+ const response = await this.get<DataWrapperWithTotal<History.Episode>>(
"/history",
params
);
@@ -44,11 +44,11 @@ class EpisodeApi extends BaseApi {
}
async historyBy(episodeid: number) {
- const response = await this.get<AsyncDataWrapper<History.Episode>>(
+ const response = await this.get<DataWrapperWithTotal<History.Episode>>(
"/history",
{ episodeid }
);
- return response;
+ return response.data;
}
async downloadSubtitles(
diff --git a/frontend/src/apis/files.ts b/frontend/src/apis/raw/files.ts
index 88913ac08..88913ac08 100644
--- a/frontend/src/apis/files.ts
+++ b/frontend/src/apis/raw/files.ts
diff --git a/frontend/src/apis/history.ts b/frontend/src/apis/raw/history.ts
index d26d89d8b..c1226cd7f 100644
--- a/frontend/src/apis/history.ts
+++ b/frontend/src/apis/raw/history.ts
@@ -6,13 +6,13 @@ class HistoryApi extends BaseApi {
}
async stats(
- timeframe?: History.TimeframeOptions,
+ timeFrame?: History.TimeFrameOptions,
action?: History.ActionOptions,
provider?: string,
language?: Language.CodeType
) {
const response = await this.get<History.Stat>("/stats", {
- timeframe,
+ timeFrame,
action,
provider,
language,
diff --git a/frontend/src/apis/raw/index.ts b/frontend/src/apis/raw/index.ts
new file mode 100644
index 000000000..5283f0f2c
--- /dev/null
+++ b/frontend/src/apis/raw/index.ts
@@ -0,0 +1,25 @@
+import badges from "./badges";
+import episodes from "./episodes";
+import files from "./files";
+import history from "./history";
+import movies from "./movies";
+import providers from "./providers";
+import series from "./series";
+import subtitles from "./subtitles";
+import system from "./system";
+import utils from "./utils";
+
+const api = {
+ badges,
+ episodes,
+ files,
+ movies,
+ series,
+ providers,
+ history,
+ subtitles,
+ system,
+ utils,
+};
+
+export default api;
diff --git a/frontend/src/apis/movies.ts b/frontend/src/apis/raw/movies.ts
index 8e94712cb..b8690fdcc 100644
--- a/frontend/src/apis/movies.ts
+++ b/frontend/src/apis/raw/movies.ts
@@ -21,14 +21,17 @@ class MovieApi extends BaseApi {
}
async movies(radarrid?: number[]) {
- const response = await this.get<AsyncDataWrapper<Item.Movie>>("", {
+ const response = await this.get<DataWrapperWithTotal<Item.Movie>>("", {
radarrid,
});
- return response;
+ return response.data;
}
async moviesBy(params: Parameter.Range) {
- const response = await this.get<AsyncDataWrapper<Item.Movie>>("", params);
+ const response = await this.get<DataWrapperWithTotal<Item.Movie>>(
+ "",
+ params
+ );
return response;
}
@@ -37,7 +40,7 @@ class MovieApi extends BaseApi {
}
async wanted(params: Parameter.Range) {
- const response = await this.get<AsyncDataWrapper<Wanted.Movie>>(
+ const response = await this.get<DataWrapperWithTotal<Wanted.Movie>>(
"/wanted",
params
);
@@ -45,14 +48,17 @@ class MovieApi extends BaseApi {
}
async wantedBy(radarrid: number[]) {
- const response = await this.get<AsyncDataWrapper<Wanted.Movie>>("/wanted", {
- radarrid,
- });
+ const response = await this.get<DataWrapperWithTotal<Wanted.Movie>>(
+ "/wanted",
+ {
+ radarrid,
+ }
+ );
return response;
}
async history(params: Parameter.Range) {
- const response = await this.get<AsyncDataWrapper<History.Movie>>(
+ const response = await this.get<DataWrapperWithTotal<History.Movie>>(
"/history",
params
);
@@ -60,11 +66,11 @@ class MovieApi extends BaseApi {
}
async historyBy(radarrid: number) {
- const response = await this.get<AsyncDataWrapper<History.Movie>>(
+ const response = await this.get<DataWrapperWithTotal<History.Movie>>(
"/history",
{ radarrid }
);
- return response;
+ return response.data;
}
async action(action: FormType.MoviesAction) {
diff --git a/frontend/src/apis/providers.ts b/frontend/src/apis/raw/providers.ts
index cfbb2dbc5..cfbb2dbc5 100644
--- a/frontend/src/apis/providers.ts
+++ b/frontend/src/apis/raw/providers.ts
diff --git a/frontend/src/apis/series.ts b/frontend/src/apis/raw/series.ts
index 976104003..d94b108df 100644
--- a/frontend/src/apis/series.ts
+++ b/frontend/src/apis/raw/series.ts
@@ -6,14 +6,17 @@ class SeriesApi extends BaseApi {
}
async series(seriesid?: number[]) {
- const response = await this.get<AsyncDataWrapper<Item.Series>>("", {
+ const response = await this.get<DataWrapperWithTotal<Item.Series>>("", {
seriesid,
});
- return response;
+ return response.data;
}
async seriesBy(params: Parameter.Range) {
- const response = await this.get<AsyncDataWrapper<Item.Series>>("", params);
+ const response = await this.get<DataWrapperWithTotal<Item.Series>>(
+ "",
+ params
+ );
return response;
}
diff --git a/frontend/src/apis/subtitles.ts b/frontend/src/apis/raw/subtitles.ts
index abfe96ba6..abfe96ba6 100644
--- a/frontend/src/apis/subtitles.ts
+++ b/frontend/src/apis/raw/subtitles.ts
diff --git a/frontend/src/apis/system.ts b/frontend/src/apis/raw/system.ts
index d21200c28..c473f076d 100644
--- a/frontend/src/apis/system.ts
+++ b/frontend/src/apis/raw/system.ts
@@ -30,7 +30,7 @@ class SystemApi extends BaseApi {
return response;
}
- async setSettings(data: object) {
+ async updateSettings(data: object) {
await this.post("/settings", data);
}
diff --git a/frontend/src/apis/utils.ts b/frontend/src/apis/raw/utils.ts
index d2adddb26..d553c8f7f 100644
--- a/frontend/src/apis/utils.ts
+++ b/frontend/src/apis/raw/utils.ts
@@ -1,4 +1,4 @@
-import apis from ".";
+import client from "../queries/client";
type UrlTestResponse =
| {
@@ -13,7 +13,7 @@ type UrlTestResponse =
class RequestUtils {
async urlTest(protocol: string, url: string, params?: any) {
try {
- const result = await apis.axios.get<UrlTestResponse>(
+ const result = await client.axios.get<UrlTestResponse>(
`../test/${protocol}/${url}api/system/status`,
{ params }
);
@@ -24,7 +24,7 @@ class RequestUtils {
throw new Error("Cannot get response, fallback to v3 api");
}
} catch (e) {
- const result = await apis.axios.get<UrlTestResponse>(
+ const result = await client.axios.get<UrlTestResponse>(
`../test/${protocol}/${url}api/v3/system/status`,
{ params }
);
diff --git a/frontend/src/special-pages/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
index f777c5018..e419d6da5 100644
--- a/frontend/src/special-pages/ErrorBoundary.tsx
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -1,5 +1,5 @@
+import UIError from "pages/UIError";
import React from "react";
-import UIError from "./UIError";
interface State {
error: Error | null;
diff --git a/frontend/src/DisplayItem/generic/ItemOverview.tsx b/frontend/src/components/ItemOverview.tsx
index 1c06b5d25..7915a2ed4 100644
--- a/frontend/src/DisplayItem/generic/ItemOverview.tsx
+++ b/frontend/src/components/ItemOverview.tsx
@@ -22,9 +22,12 @@ import {
Popover,
Row,
} from "react-bootstrap";
-import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
-import { LanguageText } from "../../components";
-import { BuildKey, isMovie } from "../../utilities";
+import { BuildKey, isMovie } from "utilities";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "utilities/languages";
+import { LanguageText } from ".";
interface Props {
item: Item.Base;
@@ -75,7 +78,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
[item.audio_language]
);
- const profile = useProfileBy(item.profileId);
+ const profile = useLanguageProfileBy(item.profileId);
const profileItems = useProfileItemsToLanguages(profile);
const languageBadges = useMemo(() => {
diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx
index f2466e1bc..1d6271a64 100644
--- a/frontend/src/components/LanguageSelector.tsx
+++ b/frontend/src/components/LanguageSelector.tsx
@@ -1,5 +1,5 @@
+import { Selector, SelectorProps } from "components";
import React, { useMemo } from "react";
-import { Selector, SelectorProps } from "../components";
interface Props {
options: readonly Language.Info[];
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index 86ad517a8..f8de27ec4 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -1,3 +1,5 @@
+import { useServerSearch } from "apis/hooks";
+import { uniqueId } from "lodash";
import React, {
FunctionComponent,
useCallback,
@@ -9,6 +11,34 @@ import { Dropdown, Form } from "react-bootstrap";
import { useHistory } from "react-router";
import { useThrottle } from "rooks";
+function useSearch(query: string) {
+ const { data } = useServerSearch(query, query.length > 0);
+
+ return useMemo(
+ () =>
+ data?.map((v) => {
+ let link: string;
+ let id: string;
+ if (v.sonarrSeriesId) {
+ link = `/series/${v.sonarrSeriesId}`;
+ id = `series-${v.sonarrSeriesId}`;
+ } else if (v.radarrId) {
+ link = `/movies/${v.radarrId}`;
+ id = `movie-${v.radarrId}`;
+ } else {
+ link = "";
+ id = uniqueId("unknown");
+ }
+
+ return {
+ name: `${v.title} (${v.year})`,
+ link,
+ id,
+ };
+ }) ?? [],
+ [data]
+ );
+}
export interface SearchResult {
id: string;
name: string;
@@ -17,43 +47,30 @@ export interface SearchResult {
interface Props {
className?: string;
- onSearch: (text: string) => Promise<SearchResult[]>;
onFocus?: () => void;
onBlur?: () => void;
}
export const SearchBar: FunctionComponent<Props> = ({
- onSearch,
onFocus,
onBlur,
className,
}) => {
- const [text, setText] = useState("");
-
- const [results, setResults] = useState<SearchResult[]>([]);
+ const [display, setDisplay] = useState("");
+ const [query, setQuery] = useState("");
- const history = useHistory();
-
- const search = useCallback(
- (value: string) => {
- if (value === "") {
- setResults([]);
- } else {
- onSearch(value).then((res) => setResults(res));
- }
- },
- [onSearch]
- );
+ const [debounce] = useThrottle(setQuery, 500);
+ useEffect(() => {
+ debounce(display);
+ }, [debounce, display]);
- const [debounceSearch] = useThrottle(search, 500);
+ const results = useSearch(query);
- useEffect(() => {
- debounceSearch(text);
- }, [text, debounceSearch]);
+ const history = useHistory();
const clear = useCallback(() => {
- setText("");
- setResults([]);
+ setDisplay("");
+ setQuery("");
}, []);
const items = useMemo(() => {
@@ -76,7 +93,7 @@ export const SearchBar: FunctionComponent<Props> = ({
return (
<Dropdown
- show={text.length !== 0}
+ show={query.length !== 0}
className={className}
onFocus={onFocus}
onBlur={onBlur}
@@ -91,8 +108,8 @@ export const SearchBar: FunctionComponent<Props> = ({
type="text"
size="sm"
placeholder="Search..."
- value={text}
- onChange={(e) => setText(e.currentTarget.value)}
+ value={display}
+ onChange={(e) => setDisplay(e.currentTarget.value)}
></Form.Control>
<Dropdown.Menu style={{ maxHeight: 256, overflowY: "auto" }}>
{items}
diff --git a/frontend/src/components/async.tsx b/frontend/src/components/async.tsx
index 12b87fcf0..105cd567e 100644
--- a/frontend/src/components/async.tsx
+++ b/frontend/src/components/async.tsx
@@ -4,38 +4,35 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { isEmpty } from "lodash";
import React, {
FunctionComponent,
PropsWithChildren,
useCallback,
useEffect,
- useMemo,
useState,
} from "react";
import { Button, ButtonProps } from "react-bootstrap";
+import { UseQueryResult } from "react-query";
import { useTimeoutWhen } from "rooks";
import { LoadingIndicator } from ".";
-import { Selector, SelectorProps } from "./inputs";
-interface Props<T extends Async.Base<any>> {
- ctx: T;
- children: FunctionComponent<T>;
+interface QueryOverlayProps {
+ result: UseQueryResult<unknown, unknown>;
+ children: React.ReactElement;
}
-export function AsyncOverlay<T extends Async.Base<any>>(props: Props<T>) {
- const { ctx, children } = props;
- if (
- ctx.state === "uninitialized" ||
- (ctx.state === "loading" && isEmpty(ctx.content))
- ) {
+export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
+ children,
+ result: { isLoading, isError, error },
+}) => {
+ if (isLoading) {
return <LoadingIndicator></LoadingIndicator>;
- } else if (ctx.state === "failed") {
- return <p>{ctx.error}</p>;
- } else {
- return children(ctx);
+ } else if (isError) {
+ return <p>{error as string}</p>;
}
-}
+
+ return children;
+};
interface PromiseProps<T> {
promise: () => Promise<T>;
@@ -58,48 +55,6 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
}
}
-type AsyncSelectorProps<V, T extends Async.Item<V[]>> = {
- state: T;
- update: () => void;
- label: (item: V) => string;
-};
-
-type RemovedSelectorProps<T, M extends boolean> = Omit<
- SelectorProps<T, M>,
- "loading" | "options" | "onFocus"
->;
-
-export function AsyncSelector<
- V,
- T extends Async.Item<V[]>,
- M extends boolean = false
->(props: Override<AsyncSelectorProps<V, T>, RemovedSelectorProps<V, M>>) {
- const { label, state, update, ...selector } = props;
-
- const options = useMemo<SelectorOption<V>[]>(
- () =>
- state.content?.map((v) => ({
- label: label(v),
- value: v,
- })) ?? [],
- [state, label]
- );
-
- return (
- <Selector
- loading={state.state === "loading"}
- options={options}
- label={label}
- onFocus={() => {
- if (state.state === "uninitialized") {
- update();
- }
- }}
- {...selector}
- ></Selector>
- );
-}
-
interface AsyncButtonProps<T> {
as?: ButtonProps["as"];
variant?: ButtonProps["variant"];
diff --git a/frontend/src/components/inputs/FileBrowser.tsx b/frontend/src/components/inputs/FileBrowser.tsx
index 8b1b15927..4dfe8c80e 100644
--- a/frontend/src/components/inputs/FileBrowser.tsx
+++ b/frontend/src/components/inputs/FileBrowser.tsx
@@ -1,6 +1,7 @@
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
import { faReply } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useFileSystem } from "apis/hooks";
import React, {
FunctionComponent,
useEffect,
@@ -31,21 +32,22 @@ function extractPath(raw: string) {
interface Props {
defaultValue?: string;
- load: (path: string) => Promise<FileTree[]>;
+ type: "sonarr" | "radarr" | "bazarr";
onChange?: (path: string) => void;
drop?: DropdownProps["drop"];
}
export const FileBrowser: FunctionComponent<Props> = ({
defaultValue,
+ type,
onChange,
- load,
drop,
}) => {
const [show, canShow] = useState(false);
const [text, setText] = useState(defaultValue ?? "");
const [path, setPath] = useState(() => extractPath(text));
- const [loading, setLoading] = useState(true);
+
+ const { data: tree, isFetching } = useFileSystem(type, path, show);
const filter = useMemo(() => {
const idx = getLastSeparator(text);
@@ -57,10 +59,8 @@ export const FileBrowser: FunctionComponent<Props> = ({
return path.slice(0, idx + 1);
}, [path]);
- const [tree, setTree] = useState<FileTree[]>([]);
-
- const requestItems = useMemo(() => {
- if (loading) {
+ const requestItems = () => {
+ if (isFetching) {
return (
<Dropdown.Item>
<Spinner size="sm" animation="border"></Spinner>
@@ -70,19 +70,21 @@ export const FileBrowser: FunctionComponent<Props> = ({
const elements = [];
- elements.push(
- ...tree
- .filter((v) => v.name.startsWith(filter))
- .map((v) => (
- <Dropdown.Item eventKey={v.path} key={v.name}>
- <FontAwesomeIcon
- icon={v.children ? faFolder : faFile}
- className="mr-2"
- ></FontAwesomeIcon>
- <span>{v.name}</span>
- </Dropdown.Item>
- ))
- );
+ if (tree) {
+ elements.push(
+ ...tree
+ .filter((v) => v.name.startsWith(filter))
+ .map((v) => (
+ <Dropdown.Item eventKey={v.path} key={v.name}>
+ <FontAwesomeIcon
+ icon={v.children ? faFolder : faFile}
+ className="mr-2"
+ ></FontAwesomeIcon>
+ <span>{v.name}</span>
+ </Dropdown.Item>
+ ))
+ );
+ }
if (elements.length === 0) {
elements.push(<Dropdown.Header key="no-files">No Files</Dropdown.Header>);
@@ -100,7 +102,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
} else {
return elements;
}
- }, [tree, filter, previous, loading]);
+ };
useEffect(() => {
if (text === path) {
@@ -116,17 +118,6 @@ export const FileBrowser: FunctionComponent<Props> = ({
const input = useRef<HTMLInputElement>(null);
- useEffect(() => {
- if (show) {
- setLoading(true);
- load(path)
- .then((res) => {
- setTree(res);
- })
- .finally(() => setLoading(false));
- }
- }, [path, load, show]);
-
return (
<Dropdown
show={show}
@@ -165,7 +156,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
className="w-100"
style={{ maxHeight: 256, overflowY: "auto" }}
>
- {requestItems}
+ {requestItems()}
</Dropdown.Menu>
</Dropdown>
);
diff --git a/frontend/src/DisplayItem/generic/blacklist.tsx b/frontend/src/components/inputs/blacklist.tsx
index 6d39f55ae..fe079a925 100644
--- a/frontend/src/DisplayItem/generic/blacklist.tsx
+++ b/frontend/src/components/inputs/blacklist.tsx
@@ -1,7 +1,7 @@
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
-import { AsyncButton } from "../../components";
+import { AsyncButton } from "..";
interface Props {
history: History.Base;
diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx
index 6a95547f3..7fe8a40f6 100644
--- a/frontend/src/components/modals/HistoryModal.tsx
+++ b/frontend/src/components/modals/HistoryModal.tsx
@@ -1,10 +1,19 @@
-import React, { FunctionComponent, useCallback, useMemo } from "react";
+import {
+ useEpisodeAddBlacklist,
+ useEpisodeHistory,
+ useMovieAddBlacklist,
+ useMovieHistory,
+} from "apis/hooks";
+import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
-import { useDidUpdate } from "rooks";
-import { HistoryIcon, LanguageText, PageTable, TextPopover } from "..";
-import { EpisodesApi, MoviesApi, useAsyncRequest } from "../../apis";
-import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
-import { AsyncOverlay } from "../async";
+import {
+ HistoryIcon,
+ LanguageText,
+ PageTable,
+ QueryOverlay,
+ TextPopover,
+} from "..";
+import { BlacklistButton } from "../inputs/blacklist";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalPayload } from "./hooks";
@@ -13,19 +22,9 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
const movie = useModalPayload<Item.Movie>(modal.modalKey);
- const [history, updateHistory] = useAsyncRequest(
- MoviesApi.historyBy.bind(MoviesApi)
- );
-
- const update = useCallback(() => {
- if (movie) {
- updateHistory(movie.radarrId);
- }
- }, [movie, updateHistory]);
+ const history = useMovieHistory(movie?.radarrId);
- useDidUpdate(() => {
- update();
- }, [movie?.radarrId]);
+ const { data } = history;
const columns = useMemo<Column<History.Movie>[]>(
() => [
@@ -74,33 +73,30 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
// Actions
accessor: "blacklisted",
Cell: ({ row }) => {
- const original = row.original;
+ const { radarrId } = row.original;
+ const { mutateAsync } = useMovieAddBlacklist();
return (
<BlacklistButton
- update={update}
- promise={(form) =>
- MoviesApi.addBlacklist(original.radarrId, form)
- }
- history={original}
+ update={history.refetch}
+ promise={(form) => mutateAsync({ id: radarrId, form })}
+ history={row.original}
></BlacklistButton>
);
},
},
],
- [update]
+ [history.refetch]
);
return (
<BaseModal title={`History - ${movie?.title ?? ""}`} {...modal}>
- <AsyncOverlay ctx={history}>
- {({ content }) => (
- <PageTable
- emptyText="No History Found"
- columns={columns}
- data={content?.data ?? []}
- ></PageTable>
- )}
- </AsyncOverlay>
+ <QueryOverlay result={history}>
+ <PageTable
+ emptyText="No History Found"
+ columns={columns}
+ data={data ?? []}
+ ></PageTable>
+ </QueryOverlay>
</BaseModal>
);
};
@@ -112,19 +108,9 @@ export const EpisodeHistoryModal: FunctionComponent<
> = (props) => {
const episode = useModalPayload<Item.Episode>(props.modalKey);
- const [history, updateHistory] = useAsyncRequest(
- EpisodesApi.historyBy.bind(EpisodesApi)
- );
-
- const update = useCallback(() => {
- if (episode) {
- updateHistory(episode.sonarrEpisodeId);
- }
- }, [episode, updateHistory]);
+ const history = useEpisodeHistory(episode?.sonarrEpisodeId);
- useDidUpdate(() => {
- update();
- }, [episode?.sonarrEpisodeId]);
+ const { data } = history;
const columns = useMemo<Column<History.Episode>[]>(
() => [
@@ -174,33 +160,36 @@ export const EpisodeHistoryModal: FunctionComponent<
accessor: "blacklisted",
Cell: ({ row }) => {
const original = row.original;
- const { sonarrSeriesId, sonarrEpisodeId } = original;
+
+ const { sonarrEpisodeId, sonarrSeriesId } = original;
+ const { mutateAsync } = useEpisodeAddBlacklist();
return (
<BlacklistButton
history={original}
- update={update}
promise={(form) =>
- EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
+ mutateAsync({
+ seriesId: sonarrSeriesId,
+ episodeId: sonarrEpisodeId,
+ form,
+ })
}
></BlacklistButton>
);
},
},
],
- [update]
+ []
);
return (
<BaseModal title={`History - ${episode?.title ?? ""}`} {...props}>
- <AsyncOverlay ctx={history}>
- {({ content }) => (
- <PageTable
- emptyText="No History Found"
- columns={columns}
- data={content?.data ?? []}
- ></PageTable>
- )}
- </AsyncOverlay>
+ <QueryOverlay result={history}>
+ <PageTable
+ emptyText="No History Found"
+ columns={columns}
+ data={data ?? []}
+ ></PageTable>
+ </QueryOverlay>
</BaseModal>
);
};
diff --git a/frontend/src/components/modals/ItemEditorModal.tsx b/frontend/src/components/modals/ItemEditorModal.tsx
index cc8a93468..d123250f3 100644
--- a/frontend/src/components/modals/ItemEditorModal.tsx
+++ b/frontend/src/components/modals/ItemEditorModal.tsx
@@ -1,9 +1,8 @@
+import { useIsAnyActionRunning, useLanguageProfiles } from "apis/hooks";
import React, { FunctionComponent, useMemo, useState } from "react";
import { Container, Form } from "react-bootstrap";
+import { GetItemId } from "utilities";
import { AsyncButton, Selector } from "../";
-import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
-import { useLanguageProfiles } from "../../@redux/hooks";
-import { GetItemId } from "../../utilities";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
@@ -15,14 +14,13 @@ interface Props {
const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
const { onSuccess, submit, ...modal } = props;
- const profiles = useLanguageProfiles();
+ const { data: profiles } = useLanguageProfiles();
const { payload, closeModal } = useModalInformation<Item.Base>(
modal.modalKey
);
- // TODO: Separate movies and series
- const hasTask = useIsAnyTaskRunningWithId([GetItemId(payload ?? {})]);
+ const hasTask = useIsAnyActionRunning();
const profileOptions = useMemo<SelectorOption<number>[]>(
() =>
@@ -43,6 +41,10 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
promise={() => {
if (payload) {
const itemId = GetItemId(payload);
+ if (!itemId) {
+ return null;
+ }
+
return submit({
id: [itemId],
profileid: [id],
diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx
index 2fb50bf99..853d93a49 100644
--- a/frontend/src/components/modals/ManualSearchModal.tsx
+++ b/frontend/src/components/modals/ManualSearchModal.tsx
@@ -6,10 +6,12 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useEpisodesProvider, useMoviesProvider } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
- useEffect,
useMemo,
useState,
} from "react";
@@ -24,6 +26,7 @@ import {
Row,
} from "react-bootstrap";
import { Column } from "react-table";
+import { GetItemId, isMovie } from "utilities";
import {
BaseModal,
BaseModalProps,
@@ -32,20 +35,10 @@ import {
PageTable,
useModalPayload,
} from "..";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { ProvidersApi } from "../../apis";
-import { GetItemId, isMovie } from "../../utilities";
import "./msmStyle.scss";
type SupportType = Item.Movie | Item.Episode;
-enum SearchState {
- Ready,
- Searching,
- Finished,
-}
-
interface Props<T extends SupportType> {
download: (item: T, result: SearchResultType) => Promise<void>;
}
@@ -55,30 +48,35 @@ export function ManualSearchModal<T extends SupportType>(
) {
const { download, ...modal } = props;
- const [result, setResult] = useState<SearchResultType[]>([]);
- const [searchState, setSearchState] = useState(SearchState.Ready);
-
const item = useModalPayload<T>(modal.modalKey);
- const search = useCallback(async () => {
+ const [episodeId, setEpisodeId] = useState<number | undefined>(undefined);
+ const [radarrId, setRadarrId] = useState<number | undefined>(undefined);
+
+ const episodes = useEpisodesProvider(episodeId);
+ const movies = useMoviesProvider(radarrId);
+
+ const isInitial = episodeId === undefined && radarrId === undefined;
+ const isFetching = episodes.isFetching || movies.isFetching;
+
+ const results = useMemo(
+ () => [...(episodes.data ?? []), ...(movies.data ?? [])],
+ [episodes.data, movies.data]
+ );
+
+ const search = useCallback(() => {
+ setEpisodeId(undefined);
+ setRadarrId(undefined);
if (item) {
- setSearchState(SearchState.Searching);
- let results: SearchResultType[] = [];
if (isMovie(item)) {
- results = await ProvidersApi.movies(item.radarrId);
+ setRadarrId(item.radarrId);
+ movies.refetch();
} else {
- results = await ProvidersApi.episodes(item.sonarrEpisodeId);
+ setEpisodeId(item.sonarrEpisodeId);
+ episodes.refetch();
}
- setResult(results);
- setSearchState(SearchState.Finished);
}
- }, [item]);
-
- useEffect(() => {
- if (item !== null) {
- setSearchState(SearchState.Ready);
- }
- }, [item]);
+ }, [episodes, item, movies]);
const columns = useMemo<Column<SearchResultType>[]>(
() => [
@@ -214,8 +212,8 @@ export function ManualSearchModal<T extends SupportType>(
[download, item]
);
- const content = useMemo<JSX.Element>(() => {
- if (searchState === SearchState.Ready) {
+ const content = () => {
+ if (isInitial) {
return (
<div className="px-4 py-5">
<p className="mb-3 small">{item?.path ?? ""}</p>
@@ -224,7 +222,7 @@ export function ManualSearchModal<T extends SupportType>(
</Button>
</div>
);
- } else if (searchState === SearchState.Searching) {
+ } else if (isFetching) {
return <LoadingIndicator animation="grow"></LoadingIndicator>;
} else {
return (
@@ -233,24 +231,21 @@ export function ManualSearchModal<T extends SupportType>(
<PageTable
emptyText="No Result"
columns={columns}
- data={result}
+ data={results}
></PageTable>
</React.Fragment>
);
}
- }, [searchState, columns, result, search, item?.path]);
+ };
- const footer = useMemo(
- () => (
- <Button
- variant="light"
- hidden={searchState !== SearchState.Finished}
- onClick={search}
- >
- Search Again
- </Button>
- ),
- [searchState, search]
+ const footer = (
+ <Button
+ variant="light"
+ hidden={isFetching === true || isInitial === true}
+ onClick={search}
+ >
+ Search Again
+ </Button>
);
const title = useMemo(() => {
@@ -270,13 +265,13 @@ export function ManualSearchModal<T extends SupportType>(
return (
<BaseModal
- closeable={searchState !== SearchState.Searching}
+ closeable={isFetching === false}
size="xl"
title={title}
footer={footer}
{...modal}
>
- {content}
+ {content()}
</BaseModal>
);
}
diff --git a/frontend/src/components/modals/MovieUploadModal.tsx b/frontend/src/components/modals/MovieUploadModal.tsx
index f96e77089..a5e2705b1 100644
--- a/frontend/src/components/modals/MovieUploadModal.tsx
+++ b/frontend/src/components/modals/MovieUploadModal.tsx
@@ -1,8 +1,11 @@
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useMovieSubtitleModification } from "apis/hooks";
import React, { FunctionComponent, useCallback } from "react";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
-import { MoviesApi } from "../../apis";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "utilities/languages";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, {
@@ -19,7 +22,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
const { payload } = useModalInformation<Item.Movie>(modal.modalKey);
- const profile = useProfileBy(payload?.profileId);
+ const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
@@ -27,6 +30,10 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
return list;
}, []);
+ const {
+ upload: { mutateAsync },
+ } = useMovieSubtitleModification();
+
const validate = useCallback<Validator<Payload>>(
(item) => {
if (item.language === null) {
@@ -64,23 +71,20 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
.map((v) => {
const { file, language, forced, hi } = v;
- return createTask(
- file.name,
- radarrId,
- MoviesApi.uploadSubtitles.bind(MoviesApi),
+ return createTask(file.name, radarrId, mutateAsync, {
radarrId,
- {
+ form: {
file,
forced,
hi,
language: language!.code2,
- }
- );
+ },
+ });
});
dispatchTask(TaskGroupName, tasks, "Uploading...");
},
- [payload]
+ [mutateAsync, payload]
);
return (
diff --git a/frontend/src/components/modals/SeriesUploadModal.tsx b/frontend/src/components/modals/SeriesUploadModal.tsx
index 6f6245905..d7c6d359c 100644
--- a/frontend/src/components/modals/SeriesUploadModal.tsx
+++ b/frontend/src/components/modals/SeriesUploadModal.tsx
@@ -1,9 +1,13 @@
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useEpisodeSubtitleModification } from "apis/hooks";
+import api from "apis/raw";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
-import { EpisodesApi, SubtitlesApi } from "../../apis";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "utilities/languages";
import { Selector } from "../inputs";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
@@ -28,17 +32,21 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
}) => {
const { payload } = useModalInformation<Item.Series>(modal.modalKey);
- const profile = useProfileBy(payload?.profileId);
+ const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
+ const {
+ upload: { mutateAsync },
+ } = useEpisodeSubtitleModification();
+
const update = useCallback(
async (list: PendingSubtitle<Payload>[]) => {
const newList = [...list];
const names = list.map((v) => v.file.name);
if (names.length > 0) {
- const results = await SubtitlesApi.info(names);
+ const results = await api.subtitles.info(names);
// TODO: Optimization
newList.forEach((v) => {
@@ -85,14 +93,14 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
return;
}
- const { sonarrSeriesId: seriesid } = payload;
+ const { sonarrSeriesId: seriesId } = payload;
const tasks = items
.filter((v) => v.payload.instance !== undefined)
.map((v) => {
const { hi, forced, payload, language } = v;
const { code2 } = language!;
- const { sonarrEpisodeId: episodeid } = payload.instance!;
+ const { sonarrEpisodeId: episodeId } = payload.instance!;
const form: FormType.UploadSubtitle = {
file: v.file,
@@ -101,19 +109,16 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
forced: forced,
};
- return createTask(
- v.file.name,
- episodeid,
- EpisodesApi.uploadSubtitles.bind(EpisodesApi),
- seriesid,
- episodeid,
- form
- );
+ return createTask(v.file.name, episodeId, mutateAsync, {
+ seriesId,
+ episodeId,
+ form,
+ });
});
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
},
- [payload]
+ [mutateAsync, payload]
);
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
diff --git a/frontend/src/components/modals/SubtitleToolModal.tsx b/frontend/src/components/modals/SubtitleToolModal.tsx
index f22eb9f38..f8891ecff 100644
--- a/frontend/src/components/modals/SubtitleToolModal.tsx
+++ b/frontend/src/components/modals/SubtitleToolModal.tsx
@@ -14,6 +14,9 @@ import {
faTextHeight,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useSubtitleAction } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
@@ -29,6 +32,9 @@ import {
InputGroup,
} from "react-bootstrap";
import { Column, useRowSelect } from "react-table";
+import { isMovie, submodProcessColor } from "utilities";
+import { useEnabledLanguages } from "utilities/languages";
+import { log } from "utilities/logger";
import {
ActionButton,
ActionButtonItem,
@@ -39,12 +45,6 @@ import {
useModalPayload,
useShowModal,
} from "..";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { useEnabledLanguages } from "../../@redux/hooks";
-import { SubtitlesApi } from "../../apis";
-import { isMovie, submodProcessColor } from "../../utilities";
-import { log } from "../../utilities/logger";
import { useCustomSelection } from "../tables/plugins";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useCloseModal } from "./hooks";
@@ -255,7 +255,7 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
process,
...modal
}) => {
- const languages = useEnabledLanguages();
+ const { data: languages } = useEnabledLanguages();
const available = useMemo(
() => languages.filter((v) => v.code2 in availableTranslation),
@@ -305,6 +305,8 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
const closeModal = useCloseModal();
+ const { mutateAsync } = useSubtitleAction();
+
const process = useCallback(
(action: string, override?: Partial<FormType.ModifySubtitle>) => {
log("info", "executing action", action);
@@ -318,18 +320,12 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
path: s.path,
...override,
};
- return createTask(
- s.path,
- s.id,
- SubtitlesApi.modify.bind(SubtitlesApi),
- action,
- form
- );
+ return createTask(s.path, s.id, mutateAsync, { action, form });
});
dispatchTask(TaskGroupName, tasks, "Modifying subtitles...");
},
- [closeModal, selections, props.modalKey]
+ [closeModal, props.modalKey, selections, mutateAsync]
);
const showModal = useShowModal();
diff --git a/frontend/src/components/modals/SubtitleUploadModal.tsx b/frontend/src/components/modals/SubtitleUploadModal.tsx
index eba982ed5..b5cb11b9d 100644
--- a/frontend/src/components/modals/SubtitleUploadModal.tsx
+++ b/frontend/src/components/modals/SubtitleUploadModal.tsx
@@ -9,8 +9,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
+import { BuildKey } from "utilities";
import { LanguageSelector, MessageIcon } from "..";
-import { BuildKey } from "../../utilities";
import { FileForm } from "../inputs";
import { SimpleTable } from "../tables";
import BaseModal, { BaseModalProps } from "./BaseModal";
diff --git a/frontend/src/components/modals/hooks.tsx b/frontend/src/components/modals/hooks.tsx
index 485261376..2b9b4c136 100644
--- a/frontend/src/components/modals/hooks.tsx
+++ b/frontend/src/components/modals/hooks.tsx
@@ -1,6 +1,6 @@
import { useCallback, useContext, useMemo } from "react";
import { useDidUpdate } from "rooks";
-import { log } from "../../utilities/logger";
+import { log } from "utilities/logger";
import { ModalContext } from "./provider";
interface ModalInformation<T> {
diff --git a/frontend/src/components/tables/AsyncPageTable.tsx b/frontend/src/components/tables/AsyncPageTable.tsx
deleted file mode 100644
index 00e7748b8..000000000
--- a/frontend/src/components/tables/AsyncPageTable.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import React, { useCallback, useEffect, useState } from "react";
-import { PluginHook, TableOptions, useTable } from "react-table";
-import { LoadingIndicator } from "..";
-import { usePageSize } from "../../@storage/local";
-import {
- ScrollToTop,
- useEntityByRange,
- useIsEntityLoaded,
-} from "../../utilities";
-import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
-import PageControl from "./PageControl";
-import { useDefaultSettings } from "./plugins";
-
-function useEntityPagination<T>(
- entity: Async.Entity<T>,
- loader: (range: Parameter.Range) => void,
- start: number,
- end: number
-): T[] {
- const { state, content } = entity;
-
- const needInit = state === "uninitialized";
- const hasEmpty = useIsEntityLoaded(content, start, end) === false;
-
- useEffect(() => {
- if (needInit || hasEmpty) {
- const length = end - start;
- loader({ start, length });
- }
- });
-
- return useEntityByRange(content, start, end);
-}
-
-type Props<T extends object> = TableOptions<T> &
- TableStyleProps<T> & {
- plugins?: PluginHook<T>[];
- entity: Async.Entity<T>;
- loader: (params: Parameter.Range) => void;
- };
-
-export default function AsyncPageTable<T extends object>(props: Props<T>) {
- const { entity, plugins, loader, ...remain } = props;
- const { style, options } = useStyleAndOptions(remain);
-
- const {
- state,
- content: { ids },
- } = entity;
-
- // Impl a new pagination system instead of hacking into existing one
- const [pageIndex, setIndex] = useState(0);
- const [pageSize] = usePageSize();
- const totalRows = ids.length;
- const pageCount = Math.ceil(totalRows / pageSize);
-
- const pageStart = pageIndex * pageSize;
- const pageEnd = pageStart + pageSize;
-
- const data = useEntityPagination(entity, loader, pageStart, pageEnd);
-
- const instance = useTable(
- {
- ...options,
- data,
- },
- useDefaultSettings,
- ...(plugins ?? [])
- );
-
- const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
- instance;
-
- const previous = useCallback(() => {
- setIndex((idx) => idx - 1);
- }, []);
-
- const next = useCallback(() => {
- setIndex((idx) => idx + 1);
- }, []);
-
- const goto = useCallback((idx: number) => {
- setIndex(idx);
- }, []);
-
- useEffect(() => {
- ScrollToTop();
- }, [pageIndex]);
-
- // Reset page index if we out of bound
- useEffect(() => {
- if (pageCount === 0) return;
-
- if (pageIndex >= pageCount) {
- setIndex(pageCount - 1);
- } else if (pageIndex < 0) {
- setIndex(0);
- }
- }, [pageIndex, pageCount]);
-
- if ((state === "loading" && data.length === 0) || state === "uninitialized") {
- return <LoadingIndicator></LoadingIndicator>;
- }
-
- return (
- <React.Fragment>
- <BaseTable
- {...style}
- headers={headerGroups}
- rows={rows}
- prepareRow={prepareRow}
- tableProps={getTableProps()}
- tableBodyProps={getTableBodyProps()}
- ></BaseTable>
- <PageControl
- count={pageCount}
- index={pageIndex}
- size={pageSize}
- total={totalRows}
- canPrevious={pageIndex > 0}
- canNext={pageIndex < pageCount - 1}
- previous={previous}
- next={next}
- goto={goto}
- ></PageControl>
- </React.Fragment>
- );
-}
diff --git a/frontend/src/components/tables/PageTable.tsx b/frontend/src/components/tables/PageTable.tsx
index 6dc839bb5..f7dfd018c 100644
--- a/frontend/src/components/tables/PageTable.tsx
+++ b/frontend/src/components/tables/PageTable.tsx
@@ -6,7 +6,7 @@ import {
useRowSelect,
useTable,
} from "react-table";
-import { ScrollToTop } from "../../utilities";
+import { ScrollToTop } from "utilities";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useCustomSelection, useDefaultSettings } from "./plugins";
diff --git a/frontend/src/components/tables/QueryPageTable.tsx b/frontend/src/components/tables/QueryPageTable.tsx
new file mode 100644
index 000000000..444e4d40f
--- /dev/null
+++ b/frontend/src/components/tables/QueryPageTable.tsx
@@ -0,0 +1,77 @@
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import React, { useEffect } from "react";
+import { PluginHook, TableOptions, useTable } from "react-table";
+import { ScrollToTop } from "utilities";
+import { LoadingIndicator } from "..";
+import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
+import PageControl from "./PageControl";
+import { useDefaultSettings } from "./plugins";
+
+type Props<T extends object> = TableOptions<T> &
+ TableStyleProps<T> & {
+ plugins?: PluginHook<T>[];
+ query: UsePaginationQueryResult<T>;
+ };
+
+export default function QueryPageTable<T extends object>(props: Props<T>) {
+ const { plugins, query, ...remain } = props;
+ const { style, options } = useStyleAndOptions(remain);
+
+ const {
+ data,
+ isLoading,
+ paginationStatus: {
+ page,
+ pageCount,
+ totalCount,
+ canPrevious,
+ canNext,
+ pageSize,
+ },
+ controls: { previousPage, nextPage, gotoPage },
+ } = query;
+
+ const instance = useTable(
+ {
+ ...options,
+ data: data?.data ?? [],
+ },
+ useDefaultSettings,
+ ...(plugins ?? [])
+ );
+
+ const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
+ instance;
+
+ useEffect(() => {
+ ScrollToTop();
+ }, [page]);
+
+ if (isLoading) {
+ return <LoadingIndicator></LoadingIndicator>;
+ }
+
+ return (
+ <React.Fragment>
+ <BaseTable
+ {...style}
+ headers={headerGroups}
+ rows={rows}
+ prepareRow={prepareRow}
+ tableProps={getTableProps()}
+ tableBodyProps={getTableBodyProps()}
+ ></BaseTable>
+ <PageControl
+ count={pageCount}
+ index={page}
+ size={pageSize}
+ total={totalCount}
+ canPrevious={canPrevious}
+ canNext={canNext}
+ previous={previousPage}
+ next={nextPage}
+ goto={gotoPage}
+ ></PageControl>
+ </React.Fragment>
+ );
+}
diff --git a/frontend/src/components/tables/index.tsx b/frontend/src/components/tables/index.tsx
index 9db3466f8..2e7cb618d 100644
--- a/frontend/src/components/tables/index.tsx
+++ b/frontend/src/components/tables/index.tsx
@@ -1,4 +1,4 @@
-export { default as AsyncPageTable } from "./AsyncPageTable";
export { default as GroupTable } from "./GroupTable";
export { default as PageTable } from "./PageTable";
+export { default as QueryPageTable } from "./QueryPageTable";
export { default as SimpleTable } from "./SimpleTable";
diff --git a/frontend/src/components/tables/plugins/useDefaultSettings.tsx b/frontend/src/components/tables/plugins/useDefaultSettings.tsx
index 72103bff5..444ee2616 100644
--- a/frontend/src/components/tables/plugins/useDefaultSettings.tsx
+++ b/frontend/src/components/tables/plugins/useDefaultSettings.tsx
@@ -1,5 +1,5 @@
import { Hooks, TableOptions } from "react-table";
-import { usePageSize } from "../../../@storage/local";
+import { usePageSize } from "utilities/storage";
const pluginName = "useLocalSettings";
diff --git a/frontend/src/components/views/HistoryView.tsx b/frontend/src/components/views/HistoryView.tsx
new file mode 100644
index 000000000..fb900218e
--- /dev/null
+++ b/frontend/src/components/views/HistoryView.tsx
@@ -0,0 +1,36 @@
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import React from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Column } from "react-table";
+import { QueryPageTable } from "..";
+
+interface Props<T extends History.Base> {
+ name: string;
+ query: UsePaginationQueryResult<T>;
+ columns: Column<T>[];
+}
+
+function HistoryView<T extends History.Base = History.Base>({
+ columns,
+ name,
+ query,
+}: Props<T>) {
+ return (
+ <Container fluid>
+ <Helmet>
+ <title>{name} History - Bazarr</title>
+ </Helmet>
+ <Row>
+ <QueryPageTable
+ emptyText={`Nothing Found in ${name} History`}
+ columns={columns}
+ query={query}
+ data={[]}
+ ></QueryPageTable>
+ </Row>
+ </Container>
+ );
+}
+
+export default HistoryView;
diff --git a/frontend/src/components/views/ItemView.tsx b/frontend/src/components/views/ItemView.tsx
new file mode 100644
index 000000000..22cd56ea8
--- /dev/null
+++ b/frontend/src/components/views/ItemView.tsx
@@ -0,0 +1,213 @@
+import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
+import { useIsAnyMutationRunning, useLanguageProfiles } from "apis/hooks";
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import { TableStyleProps } from "components/tables/BaseTable";
+import { useCustomSelection } from "components/tables/plugins";
+import { uniqBy } from "lodash";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { Container, Dropdown, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { UseMutationResult, UseQueryResult } from "react-query";
+import { Column, TableOptions, TableUpdater, useRowSelect } from "react-table";
+import { GetItemId } from "utilities";
+import {
+ ContentHeader,
+ ItemEditorModal,
+ LoadingIndicator,
+ QueryPageTable,
+ SimpleTable,
+ useShowModal,
+} from "..";
+
+interface Props<T extends Item.Base = Item.Base> {
+ name: string;
+ fullQuery: UseQueryResult<T[]>;
+ query: UsePaginationQueryResult<T>;
+ columns: Column<T>[];
+ mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
+}
+
+function ItemView<T extends Item.Base>({
+ name,
+ fullQuery,
+ query,
+ columns,
+ mutation,
+}: Props<T>) {
+ const [editMode, setEditMode] = useState(false);
+
+ const { mutateAsync } = mutation;
+
+ const showModal = useShowModal();
+
+ const updateRow = useCallback<TableUpdater<T>>(
+ ({ original }, modalKey: string) => {
+ showModal(modalKey, original);
+ },
+ [showModal]
+ );
+
+ const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
+ emptyText: `No ${name} Found`,
+ update: updateRow,
+ };
+
+ const content = editMode ? (
+ <ItemMassEditor
+ query={fullQuery}
+ columns={columns}
+ mutation={mutation}
+ onEnded={() => setEditMode(false)}
+ ></ItemMassEditor>
+ ) : (
+ <>
+ <ContentHeader scroll={false}>
+ <ContentHeader.Button
+ disabled={query.paginationStatus.totalCount === 0}
+ icon={faList}
+ onClick={() => setEditMode(true)}
+ >
+ Mass Edit
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <QueryPageTable
+ {...options}
+ columns={columns}
+ query={query}
+ data={[]}
+ ></QueryPageTable>
+ <ItemEditorModal modalKey="edit" submit={mutateAsync}></ItemEditorModal>
+ </Row>
+ </>
+ );
+
+ return (
+ <Container fluid>
+ <Helmet>
+ <title>{name} - Bazarr</title>
+ </Helmet>
+ {content}
+ </Container>
+ );
+}
+
+interface ItemMassEditorProps<T extends Item.Base> {
+ columns: Column<T>[];
+ query: UseQueryResult<T[]>;
+ mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
+ onEnded: () => void;
+}
+
+function ItemMassEditor<T extends Item.Base = Item.Base>(
+ props: ItemMassEditorProps<T>
+) {
+ const { columns, mutation, query, onEnded } = props;
+ const [selections, setSelections] = useState<T[]>([]);
+ const [dirties, setDirties] = useState<T[]>([]);
+ const hasTask = useIsAnyMutationRunning();
+ const { data: profiles } = useLanguageProfiles();
+
+ const { refetch } = query;
+
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+
+ const data = useMemo(
+ () => uniqBy([...dirties, ...(query?.data ?? [])], GetItemId),
+ [dirties, query?.data]
+ );
+
+ const profileOptions = useMemo<JSX.Element[]>(() => {
+ const items: JSX.Element[] = [];
+ if (profiles) {
+ items.push(
+ <Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
+ );
+ items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
+ items.push(
+ ...profiles.map((v) => (
+ <Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
+ {v.name}
+ </Dropdown.Item>
+ ))
+ );
+ }
+
+ return items;
+ }, [profiles]);
+
+ const { mutateAsync } = mutation;
+
+ const save = useCallback(() => {
+ const form: FormType.ModifyItem = {
+ id: [],
+ profileid: [],
+ };
+ dirties.forEach((v) => {
+ const id = GetItemId(v);
+ if (id) {
+ form.id.push(id);
+ form.profileid.push(v.profileId);
+ }
+ });
+ return mutateAsync(form);
+ }, [dirties, mutateAsync]);
+
+ const setProfiles = useCallback(
+ (key: Nullable<string>) => {
+ const id = key ? parseInt(key) : null;
+
+ const newItems = selections.map((v) => ({ ...v, profileId: id }));
+
+ setDirties((dirty) => {
+ return uniqBy([...newItems, ...dirty], GetItemId);
+ });
+ },
+ [selections]
+ );
+
+ return (
+ <>
+ <ContentHeader scroll={false}>
+ <ContentHeader.Group pos="start">
+ <Dropdown onSelect={setProfiles}>
+ <Dropdown.Toggle disabled={selections.length === 0} variant="light">
+ Change Profile
+ </Dropdown.Toggle>
+ <Dropdown.Menu>{profileOptions}</Dropdown.Menu>
+ </Dropdown>
+ </ContentHeader.Group>
+ <ContentHeader.Group pos="end">
+ <ContentHeader.Button icon={faUndo} onClick={onEnded}>
+ Cancel
+ </ContentHeader.Button>
+ <ContentHeader.AsyncButton
+ icon={faCheck}
+ disabled={dirties.length === 0 || hasTask}
+ promise={save}
+ onSuccess={onEnded}
+ >
+ Save
+ </ContentHeader.AsyncButton>
+ </ContentHeader.Group>
+ </ContentHeader>
+ <Row>
+ {query.data === undefined ? (
+ <LoadingIndicator></LoadingIndicator>
+ ) : (
+ <SimpleTable
+ columns={columns}
+ data={data}
+ onSelect={setSelections}
+ isSelecting
+ plugins={[useRowSelect, useCustomSelection]}
+ ></SimpleTable>
+ )}
+ </Row>
+ </>
+ );
+}
+
+export default ItemView;
diff --git a/frontend/src/components/views/WantedView.tsx b/frontend/src/components/views/WantedView.tsx
new file mode 100644
index 000000000..ef0895066
--- /dev/null
+++ b/frontend/src/components/views/WantedView.tsx
@@ -0,0 +1,60 @@
+import { faSearch } from "@fortawesome/free-solid-svg-icons";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useIsAnyActionRunning } from "apis/hooks";
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import React from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Column } from "react-table";
+import { ContentHeader, QueryPageTable } from "..";
+
+interface Props<T extends Wanted.Base> {
+ name: string;
+ columns: Column<T>[];
+ query: UsePaginationQueryResult<T>;
+ searchAll: () => Promise<void>;
+}
+
+const TaskGroupName = "Searching wanted subtitles...";
+
+function WantedView<T extends Wanted.Base>({
+ name,
+ columns,
+ query,
+ searchAll,
+}: Props<T>) {
+ // TODO
+ const dataCount = query.paginationStatus.totalCount;
+ const hasTask = useIsAnyActionRunning();
+
+ return (
+ <Container fluid>
+ <Helmet>
+ <title>Wanted {name} - Bazarr</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.Button
+ disabled={hasTask || dataCount === 0}
+ onClick={() => {
+ const task = createTask(name, undefined, searchAll);
+ dispatchTask(TaskGroupName, [task], "Searching...");
+ }}
+ icon={faSearch}
+ >
+ Search All
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <QueryPageTable
+ emptyText={`No Missing ${name} Subtitles`}
+ query={query}
+ columns={columns}
+ data={[]}
+ ></QueryPageTable>
+ </Row>
+ </Container>
+ );
+}
+
+export default WantedView;
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index 4ff24f633..47b50ce26 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -1,7 +1,26 @@
import "@fontsource/roboto/300.css";
import React from "react";
import ReactDOM from "react-dom";
+import { QueryClientProvider } from "react-query";
+import { ReactQueryDevtools } from "react-query/devtools";
+import { Provider } from "react-redux";
+import store from "./@redux/store";
import "./@scss/index.scss";
+import queryClient from "./apis/queries";
import App from "./App";
+import { Environment, isTestEnv } from "./utilities";
+export const Entrance = () => (
+ <Provider store={store}>
+ <QueryClientProvider client={queryClient}>
+ {/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
+ {/* <React.StrictMode> */}
+ {Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
+ <App></App>
+ {/* </React.StrictMode> */}
+ </QueryClientProvider>
+ </Provider>
+);
-ReactDOM.render(<App></App>, document.getElementById("root"));
+if (!isTestEnv) {
+ ReactDOM.render(<Entrance />, document.getElementById("root"));
+}
diff --git a/frontend/src/special-pages/404.tsx b/frontend/src/pages/404.tsx
index 2d0033523..2d0033523 100644
--- a/frontend/src/special-pages/404.tsx
+++ b/frontend/src/pages/404.tsx
diff --git a/frontend/src/special-pages/AuthPage.scss b/frontend/src/pages/Authentication.scss
index 26b2bb602..26b2bb602 100644
--- a/frontend/src/special-pages/AuthPage.scss
+++ b/frontend/src/pages/Authentication.scss
diff --git a/frontend/src/special-pages/AuthPage.tsx b/frontend/src/pages/Authentication.tsx
index 5c4c9dc8b..4b2ff721b 100644
--- a/frontend/src/special-pages/AuthPage.tsx
+++ b/frontend/src/pages/Authentication.tsx
@@ -1,43 +1,22 @@
-import React, { FunctionComponent, useCallback, useState } from "react";
-import {
- Alert,
- Button,
- Card,
- Collapse,
- Form,
- Image,
- Spinner,
-} from "react-bootstrap";
+import { useReduxStore } from "@redux/hooks/base";
+import logo from "@static/logo128.png";
+import { useSystem } from "apis/hooks";
+import React, { FunctionComponent, useState } from "react";
+import { Button, Card, Form, Image, Spinner } from "react-bootstrap";
import { Redirect } from "react-router-dom";
-import { useReduxStore } from "../@redux/hooks/base";
-import logo from "../@static/logo128.png";
-import { SystemApi } from "../apis";
-import "./AuthPage.scss";
+import "./Authentication.scss";
interface Props {}
-const AuthPage: FunctionComponent<Props> = () => {
+const Authentication: FunctionComponent<Props> = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
- const [error, setError] = useState("");
- const [updating, setUpdate] = useState(false);
+ const { login, isWorking } = useSystem();
- const updateError = useCallback((msg: string) => {
- setError(msg);
- setTimeout(() => setError(""), 2000);
- }, []);
+ const authenticated = useReduxStore((s) => s.status !== "unauthenticated");
- const onSuccess = useCallback(() => window.location.reload(), []);
-
- const authState = useReduxStore((s) => s.site.auth);
-
- const onError = useCallback(() => {
- setUpdate(false);
- updateError("Login Failed");
- }, [updateError]);
-
- if (authState) {
+ if (authenticated) {
return <Redirect to="/"></Redirect>;
}
@@ -47,12 +26,7 @@ const AuthPage: FunctionComponent<Props> = () => {
<Form
onSubmit={(e) => {
e.preventDefault();
- if (!updating) {
- setUpdate(true);
- SystemApi.login(username, password)
- .then(onSuccess)
- .catch(onError);
- }
+ login({ username, password });
}}
>
<Card.Body>
@@ -61,7 +35,7 @@ const AuthPage: FunctionComponent<Props> = () => {
</Form.Group>
<Form.Group>
<Form.Control
- disabled={updating}
+ disabled={isWorking}
name="username"
type="text"
placeholder="Username"
@@ -71,7 +45,7 @@ const AuthPage: FunctionComponent<Props> = () => {
</Form.Group>
<Form.Group>
<Form.Control
- disabled={updating}
+ disabled={isWorking}
name="password"
type="password"
placeholder="Password"
@@ -79,17 +53,17 @@ const AuthPage: FunctionComponent<Props> = () => {
onChange={(e) => setPassword(e.currentTarget.value)}
></Form.Control>
</Form.Group>
- <Collapse in={error.length !== 0}>
+ {/* <Collapse in={error.length !== 0}>
<div>
<Alert variant="danger" className="m-0">
{error}
</Alert>
</div>
- </Collapse>
+ </Collapse> */}
</Card.Body>
<Card.Footer>
- <Button type="submit" disabled={updating} block>
- {updating ? (
+ <Button type="submit" disabled={isWorking} block>
+ {isWorking ? (
<Spinner size="sm" animation="border"></Spinner>
) : (
"LOGIN"
@@ -102,4 +76,4 @@ const AuthPage: FunctionComponent<Props> = () => {
);
};
-export default AuthPage;
+export default Authentication;
diff --git a/frontend/src/pages/Blacklist/Movies/index.tsx b/frontend/src/pages/Blacklist/Movies/index.tsx
new file mode 100644
index 000000000..d2eb9b8cd
--- /dev/null
+++ b/frontend/src/pages/Blacklist/Movies/index.tsx
@@ -0,0 +1,40 @@
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { useMovieBlacklist, useMovieDeleteBlacklist } from "apis/hooks/movies";
+import { ContentHeader, QueryOverlay } from "components";
+import React, { FunctionComponent } from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import Table from "./table";
+
+interface Props {}
+
+const BlacklistMoviesView: FunctionComponent<Props> = () => {
+ const blacklist = useMovieBlacklist();
+ const { data } = blacklist;
+
+ const { mutateAsync } = useMovieDeleteBlacklist();
+
+ return (
+ <QueryOverlay result={blacklist}>
+ <Container fluid>
+ <Helmet>
+ <title>Movies Blacklist - Bazarr</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.AsyncButton
+ icon={faTrash}
+ disabled={data?.length === 0}
+ promise={() => mutateAsync({ all: true })}
+ >
+ Remove All
+ </ContentHeader.AsyncButton>
+ </ContentHeader>
+ <Row>
+ <Table blacklist={data ?? []}></Table>
+ </Row>
+ </Container>
+ </QueryOverlay>
+ );
+};
+
+export default BlacklistMoviesView;
diff --git a/frontend/src/Blacklist/Movies/table.tsx b/frontend/src/pages/Blacklist/Movies/table.tsx
index 4c642d344..d7d6df75f 100644
--- a/frontend/src/Blacklist/Movies/table.tsx
+++ b/frontend/src/pages/Blacklist/Movies/table.tsx
@@ -1,15 +1,10 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useMovieDeleteBlacklist } from "apis/hooks";
+import { AsyncButton, LanguageText, PageTable, TextPopover } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { MoviesApi } from "../../apis";
-import {
- AsyncButton,
- LanguageText,
- PageTable,
- TextPopover,
-} from "../../components";
interface Props {
blacklist: readonly Blacklist.Movie[];
@@ -63,8 +58,8 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
},
{
accessor: "subs_id",
- Cell: (row) => {
- const subs_id = row.value;
+ Cell: ({ row, value }) => {
+ const { mutateAsync } = useMovieDeleteBlacklist();
return (
<AsyncButton
@@ -72,9 +67,12 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
variant="light"
noReset
promise={() =>
- MoviesApi.deleteBlacklist(false, {
- provider: row.row.original.provider,
- subs_id,
+ mutateAsync({
+ all: false,
+ form: {
+ provider: row.original.provider,
+ subs_id: value,
+ },
})
}
>
diff --git a/frontend/src/pages/Blacklist/Series/index.tsx b/frontend/src/pages/Blacklist/Series/index.tsx
new file mode 100644
index 000000000..07870c747
--- /dev/null
+++ b/frontend/src/pages/Blacklist/Series/index.tsx
@@ -0,0 +1,39 @@
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { useEpisodeBlacklist, useEpisodeDeleteBlacklist } from "apis/hooks";
+import { ContentHeader, QueryOverlay } from "components";
+import React, { FunctionComponent } from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import Table from "./table";
+
+interface Props {}
+
+const BlacklistSeriesView: FunctionComponent<Props> = () => {
+ const blacklist = useEpisodeBlacklist();
+ const { mutateAsync } = useEpisodeDeleteBlacklist();
+
+ const { data } = blacklist;
+ return (
+ <QueryOverlay result={blacklist}>
+ <Container fluid>
+ <Helmet>
+ <title>Series Blacklist - Bazarr</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.AsyncButton
+ icon={faTrash}
+ disabled={data?.length === 0}
+ promise={() => mutateAsync({ all: true })}
+ >
+ Remove All
+ </ContentHeader.AsyncButton>
+ </ContentHeader>
+ <Row>
+ <Table blacklist={data ?? []}></Table>
+ </Row>
+ </Container>
+ </QueryOverlay>
+ );
+};
+
+export default BlacklistSeriesView;
diff --git a/frontend/src/Blacklist/Series/table.tsx b/frontend/src/pages/Blacklist/Series/table.tsx
index 6448389a6..0c522cb4f 100644
--- a/frontend/src/Blacklist/Series/table.tsx
+++ b/frontend/src/pages/Blacklist/Series/table.tsx
@@ -1,15 +1,10 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useEpisodeDeleteBlacklist } from "apis/hooks";
+import { AsyncButton, LanguageText, PageTable, TextPopover } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { EpisodesApi } from "../../apis";
-import {
- AsyncButton,
- LanguageText,
- PageTable,
- TextPopover,
-} from "../../components";
interface Props {
blacklist: readonly Blacklist.Episode[];
@@ -70,17 +65,21 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
},
{
accessor: "subs_id",
- Cell: (row) => {
- const subs_id = row.value;
+ Cell: ({ row, value }) => {
+ const { mutateAsync } = useEpisodeDeleteBlacklist();
+
return (
<AsyncButton
size="sm"
variant="light"
noReset
promise={() =>
- EpisodesApi.deleteBlacklist(false, {
- provider: row.row.original.provider,
- subs_id,
+ mutateAsync({
+ all: false,
+ form: {
+ provider: row.original.provider,
+ subs_id: value,
+ },
})
}
>
diff --git a/frontend/src/DisplayItem/Episodes/components.tsx b/frontend/src/pages/Episodes/components.tsx
index 7f9c34bfb..eb440fb82 100644
--- a/frontend/src/DisplayItem/Episodes/components.tsx
+++ b/frontend/src/pages/Episodes/components.tsx
@@ -1,20 +1,20 @@
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useEpisodeSubtitleModification } from "apis/hooks";
+import { AsyncButton, LanguageText } from "components";
import React, { FunctionComponent } from "react";
import { Badge } from "react-bootstrap";
-import { EpisodesApi } from "../../apis";
-import { AsyncButton, LanguageText } from "../../components";
interface Props {
- seriesid: number;
- episodeid: number;
+ seriesId: number;
+ episodeId: number;
missing?: boolean;
subtitle: Subtitle;
}
export const SubtitleAction: FunctionComponent<Props> = ({
- seriesid,
- episodeid,
+ seriesId,
+ episodeId,
missing,
subtitle,
}) => {
@@ -22,22 +22,27 @@ export const SubtitleAction: FunctionComponent<Props> = ({
const path = subtitle.path;
+ const { download, remove } = useEpisodeSubtitleModification();
+
if (missing || path) {
return (
<AsyncButton
promise={() => {
if (missing) {
- return EpisodesApi.downloadSubtitles(seriesid, episodeid, {
- hi,
- forced,
- language: subtitle.code2,
+ return download.mutateAsync({
+ seriesId,
+ episodeId,
+ form: {
+ hi,
+ forced,
+ language: subtitle.code2,
+ },
});
} else if (path) {
- return EpisodesApi.deleteSubtitles(seriesid, episodeid, {
- hi,
- forced,
- path: path,
- language: subtitle.code2,
+ return remove.mutateAsync({
+ seriesId,
+ episodeId,
+ form: { hi, forced, path, language: subtitle.code2 },
});
} else {
return null;
diff --git a/frontend/src/DisplayItem/Episodes/index.tsx b/frontend/src/pages/Episodes/index.tsx
index 0236ca42b..cf90ac26f 100644
--- a/frontend/src/DisplayItem/Episodes/index.tsx
+++ b/frontend/src/pages/Episodes/index.tsx
@@ -7,25 +7,29 @@ import {
faSync,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent, useMemo, useState } from "react";
-import { Alert, Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
-import { dispatchTask } from "../../@modules/task";
-import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
-import { createTask } from "../../@modules/task/utilities";
-import { useEpisodesBy, useProfileBy, useSerieBy } from "../../@redux/hooks";
-import { SeriesApi } from "../../apis";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import {
+ useEpisodesBySeriesId,
+ useIsAnyActionRunning,
+ useSeriesAction,
+ useSeriesById,
+ useSeriesModification,
+} from "apis/hooks";
import {
ContentHeader,
ItemEditorModal,
LoadingIndicator,
SeriesUploadModal,
useShowModal,
-} from "../../components";
-import { RouterEmptyPath } from "../../special-pages/404";
-import { useOnLoadedOnce } from "../../utilities";
-import ItemOverview from "../generic/ItemOverview";
+} from "components";
+import ItemOverview from "components/ItemOverview";
+import { RouterEmptyPath } from "pages/404";
+import React, { FunctionComponent, useMemo } from "react";
+import { Alert, Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
+import { useLanguageProfileBy } from "utilities/languages";
import Table from "./table";
interface Params {
@@ -37,55 +41,46 @@ interface Props extends RouteComponentProps<Params> {}
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
const { match } = props;
const id = Number.parseInt(match.params.id);
- const series = useSerieBy(id);
- const episodes = useEpisodesBy(id);
- const serie = series.content;
+ const { data: series, isFetched } = useSeriesById(id);
+ const { data: episodes } = useEpisodesBySeriesId(id);
+
+ const { mutateAsync } = useSeriesModification();
+ const { mutateAsync: action } = useSeriesAction();
- const available = episodes.content.length !== 0;
+ const available = episodes?.length !== 0;
const details = useMemo(
() => [
{
icon: faHdd,
- text: `${serie?.episodeFileCount} files`,
+ text: `${series?.episodeFileCount} files`,
},
{
icon: faAdjust,
- text: serie?.seriesType ?? "",
+ text: series?.seriesType ?? "",
},
],
- [serie]
+ [series]
);
const showModal = useShowModal();
- const [valid, setValid] = useState(true);
-
- useOnLoadedOnce(() => {
- if (series.content === null) {
- setValid(false);
- }
- }, series);
+ const profile = useLanguageProfileBy(series?.profileId);
- const profile = useProfileBy(series.content?.profileId);
+ const hasTask = useIsAnyActionRunning();
- const hasTask = useIsAnyTaskRunningWithId([
- ...episodes.content.map((v) => v.sonarrEpisodeId),
- id,
- ]);
-
- if (isNaN(id) || !valid) {
+ if (isNaN(id) || (isFetched && !series)) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
- if (!serie) {
+ if (!series) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<Container fluid>
<Helmet>
- <title>{serie.title} - Bazarr (Series)</title>
+ <title>{series.title} - Bazarr (Series)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Group pos="start">
@@ -93,15 +88,10 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
icon={faSync}
disabled={!available || hasTask}
onClick={() => {
- const task = createTask(
- serie.title,
- id,
- SeriesApi.action.bind(SeriesApi),
- {
- action: "scan-disk",
- seriesid: id,
- }
- );
+ const task = createTask(series.title, id, action, {
+ action: "scan-disk",
+ seriesid: id,
+ });
dispatchTask("Scanning disk...", [task], "Scanning...");
}}
>
@@ -110,20 +100,15 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
<ContentHeader.Button
icon={faSearch}
onClick={() => {
- const task = createTask(
- serie.title,
- id,
- SeriesApi.action.bind(SeriesApi),
- {
- action: "search-missing",
- seriesid: id,
- }
- );
+ const task = createTask(series.title, id, action, {
+ action: "search-missing",
+ seriesid: id,
+ });
dispatchTask("Searching subtitles...", [task], "Searching...");
}}
disabled={
- serie.episodeFileCount === 0 ||
- serie.profileId === null ||
+ series.episodeFileCount === 0 ||
+ series.profileId === null ||
!available
}
>
@@ -132,27 +117,27 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button
- disabled={serie.episodeFileCount === 0 || !available || hasTask}
+ disabled={series.episodeFileCount === 0 || !available || hasTask}
icon={faBriefcase}
- onClick={() => showModal("tools", episodes.content)}
+ onClick={() => showModal("tools", episodes)}
>
Tools
</ContentHeader.Button>
<ContentHeader.Button
disabled={
- serie.episodeFileCount === 0 ||
- serie.profileId === null ||
+ series.episodeFileCount === 0 ||
+ series.profileId === null ||
!available
}
icon={faCloudUploadAlt}
- onClick={() => showModal("upload", serie)}
+ onClick={() => showModal("upload", series)}
>
Upload
</ContentHeader.Button>
<ContentHeader.Button
icon={faWrench}
disabled={hasTask}
- onClick={() => showModal("edit", serie)}
+ onClick={() => showModal("edit", series)}
>
Edit Series
</ContentHeader.Button>
@@ -169,23 +154,24 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
</Alert>
</Row>
<Row>
- <ItemOverview item={serie} details={details}></ItemOverview>
+ <ItemOverview item={series} details={details}></ItemOverview>
</Row>
<Row>
- <Table
- serie={series}
- episodes={episodes}
- profile={profile}
- disabled={hasTask}
- ></Table>
+ {episodes === undefined ? (
+ <LoadingIndicator></LoadingIndicator>
+ ) : (
+ <Table
+ series={series}
+ episodes={episodes}
+ profile={profile}
+ disabled={hasTask}
+ ></Table>
+ )}
</Row>
- <ItemEditorModal
- modalKey="edit"
- submit={(form) => SeriesApi.modify(form)}
- ></ItemEditorModal>
+ <ItemEditorModal modalKey="edit" submit={mutateAsync}></ItemEditorModal>
<SeriesUploadModal
modalKey="upload"
- episodes={episodes.content}
+ episodes={episodes ?? []}
></SeriesUploadModal>
</Container>
);
diff --git a/frontend/src/DisplayItem/Episodes/table.tsx b/frontend/src/pages/Episodes/table.tsx
index 24131d82a..cfd9c2593 100644
--- a/frontend/src/DisplayItem/Episodes/table.tsx
+++ b/frontend/src/pages/Episodes/table.tsx
@@ -6,49 +6,33 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import React, { FunctionComponent, useCallback, useMemo } from "react";
-import { Badge, ButtonGroup } from "react-bootstrap";
-import { Column, TableUpdater } from "react-table";
-import { useProfileItemsToLanguages } from "../../@redux/hooks";
-import { useShowOnlyDesired } from "../../@redux/hooks/site";
-import { ProvidersApi } from "../../apis";
+import { useShowOnlyDesired } from "@redux/hooks";
+import { useDownloadEpisodeSubtitles } from "apis/hooks";
import {
ActionButton,
- AsyncOverlay,
EpisodeHistoryModal,
GroupTable,
SubtitleToolModal,
TextPopover,
useShowModal,
-} from "../../components";
-import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
-import { BuildKey, filterSubtitleBy } from "../../utilities";
+} from "components";
+import { ManualSearchModal } from "components/modals/ManualSearchModal";
+import React, { FunctionComponent, useCallback, useMemo } from "react";
+import { Badge, ButtonGroup } from "react-bootstrap";
+import { Column, TableUpdater } from "react-table";
+import { BuildKey, filterSubtitleBy } from "utilities";
+import { useProfileItemsToLanguages } from "utilities/languages";
import { SubtitleAction } from "./components";
interface Props {
- serie: Async.Item<Item.Series>;
- episodes: Async.Base<Item.Episode[]>;
+ series?: Item.Series;
+ episodes: Item.Episode[];
disabled?: boolean;
profile?: Language.Profile;
}
-const download = (item: Item.Episode, result: SearchResultType) => {
- const { language, hearing_impaired, forced, provider, subtitle } = result;
- return ProvidersApi.downloadEpisodeSubtitle(
- item.sonarrSeriesId,
- item.sonarrEpisodeId,
- {
- language,
- hi: hearing_impaired,
- forced,
- provider,
- subtitle,
- }
- );
-};
-
const Table: FunctionComponent<Props> = ({
- serie,
+ series,
episodes,
profile,
disabled,
@@ -58,6 +42,33 @@ const Table: FunctionComponent<Props> = ({
const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItemsToLanguages(profile);
+ const { mutateAsync } = useDownloadEpisodeSubtitles();
+
+ const download = useCallback(
+ (item: Item.Episode, result: SearchResultType) => {
+ const {
+ language,
+ hearing_impaired: hi,
+ forced,
+ provider,
+ subtitle,
+ } = result;
+ const { sonarrSeriesId: seriesId, sonarrEpisodeId: episodeId } = item;
+
+ return mutateAsync({
+ seriesId,
+ episodeId,
+ form: {
+ language,
+ hi,
+ forced,
+ provider,
+ subtitle,
+ },
+ });
+ },
+ [mutateAsync]
+ );
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
() => [
@@ -118,8 +129,8 @@ const Table: FunctionComponent<Props> = ({
<SubtitleAction
missing
key={BuildKey(idx, val.code2, "missing")}
- seriesid={seriesid}
- episodeid={episodeid}
+ seriesId={seriesid}
+ episodeId={episodeid}
subtitle={val}
></SubtitleAction>
));
@@ -132,8 +143,8 @@ const Table: FunctionComponent<Props> = ({
const subtitles = raw_subtitles.map((val, idx) => (
<SubtitleAction
key={BuildKey(idx, val.code2, "valid")}
- seriesid={seriesid}
- episodeid={episodeid}
+ seriesId={seriesid}
+ episodeId={episodeid}
subtitle={val}
></SubtitleAction>
));
@@ -152,7 +163,7 @@ const Table: FunctionComponent<Props> = ({
<ButtonGroup>
<ActionButton
icon={faUser}
- disabled={serie.content?.profileId === null || disabled}
+ disabled={series?.profileId === null || disabled}
onClick={() => {
update && update(row, "manual-search");
}}
@@ -176,7 +187,7 @@ const Table: FunctionComponent<Props> = ({
},
},
],
- [onlyDesired, profileItems, serie, disabled]
+ [onlyDesired, profileItems, series, disabled]
);
const updateRow = useCallback<TableUpdater<Item.Episode>>(
@@ -192,35 +203,28 @@ const Table: FunctionComponent<Props> = ({
const maxSeason = useMemo(
() =>
- episodes.content.reduce<number>(
- (prev, curr) => Math.max(prev, curr.season),
- 0
- ),
+ episodes.reduce<number>((prev, curr) => Math.max(prev, curr.season), 0),
[episodes]
);
return (
<React.Fragment>
- <AsyncOverlay ctx={episodes}>
- {({ content }) => (
- <GroupTable
- columns={columns}
- data={content}
- update={updateRow}
- initialState={{
- sortBy: [
- { id: "season", desc: true },
- { id: "episode", desc: true },
- ],
- groupBy: ["season"],
- expanded: {
- [`season:${maxSeason}`]: true,
- },
- }}
- emptyText="No Episode Found For This Series"
- ></GroupTable>
- )}
- </AsyncOverlay>
+ <GroupTable
+ columns={columns}
+ data={episodes}
+ update={updateRow}
+ initialState={{
+ sortBy: [
+ { id: "season", desc: true },
+ { id: "episode", desc: true },
+ ],
+ groupBy: ["season"],
+ expanded: {
+ [`season:${maxSeason}`]: true,
+ },
+ }}
+ emptyText="No Episode Found For This Series"
+ ></GroupTable>
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
<ManualSearchModal
diff --git a/frontend/src/History/Movies/index.tsx b/frontend/src/pages/History/Movies/index.tsx
index 6523e5f1c..de2ce0098 100644
--- a/frontend/src/History/Movies/index.tsx
+++ b/frontend/src/pages/History/Movies/index.tsx
@@ -1,23 +1,17 @@
import { faInfoCircle, faRecycle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useMovieAddBlacklist, useMovieHistoryPagination } from "apis/hooks";
+import { HistoryIcon, LanguageText, TextPopover } from "components";
+import { BlacklistButton } from "components/inputs/blacklist";
+import HistoryView from "components/views/HistoryView";
import React, { FunctionComponent, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { movieUpdateHistoryByRange } from "../../@redux/actions";
-import { useMoviesHistory } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { MoviesApi } from "../../apis";
-import { HistoryIcon, LanguageText, TextPopover } from "../../components";
-import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
-import HistoryGenericView from "../generic";
interface Props {}
const MoviesHistoryView: FunctionComponent<Props> = () => {
- const movies = useMoviesHistory();
- const loader = useReduxAction(movieUpdateHistoryByRange);
-
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
() => [
{
@@ -112,13 +106,12 @@ const MoviesHistoryView: FunctionComponent<Props> = () => {
{
accessor: "blacklisted",
Cell: ({ row }) => {
- const original = row.original;
+ const { radarrId } = row.original;
+ const { mutateAsync } = useMovieAddBlacklist();
return (
<BlacklistButton
- history={original}
- promise={(form) =>
- MoviesApi.addBlacklist(original.radarrId, form)
- }
+ history={row.original}
+ promise={(form) => mutateAsync({ id: radarrId, form })}
></BlacklistButton>
);
},
@@ -127,13 +120,10 @@ const MoviesHistoryView: FunctionComponent<Props> = () => {
[]
);
+ const query = useMovieHistoryPagination();
+
return (
- <HistoryGenericView
- type="movies"
- state={movies}
- loader={loader}
- columns={columns}
- ></HistoryGenericView>
+ <HistoryView name="Movies" query={query} columns={columns}></HistoryView>
);
};
diff --git a/frontend/src/History/Series/index.tsx b/frontend/src/pages/History/Series/index.tsx
index ab051fc2b..143f360b8 100644
--- a/frontend/src/History/Series/index.tsx
+++ b/frontend/src/pages/History/Series/index.tsx
@@ -1,23 +1,20 @@
import { faInfoCircle, faRecycle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ useEpisodeAddBlacklist,
+ useEpisodeHistoryPagination,
+} from "apis/hooks";
+import { HistoryIcon, LanguageText, TextPopover } from "components";
+import { BlacklistButton } from "components/inputs/blacklist";
+import HistoryView from "components/views/HistoryView";
import React, { FunctionComponent, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { episodesUpdateHistoryByRange } from "../../@redux/actions";
-import { useSeriesHistory } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { EpisodesApi } from "../../apis";
-import { HistoryIcon, LanguageText, TextPopover } from "../../components";
-import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
-import HistoryGenericView from "../generic";
interface Props {}
const SeriesHistoryView: FunctionComponent<Props> = () => {
- const series = useSeriesHistory();
- const loader = useReduxAction(episodesUpdateHistoryByRange);
-
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
() => [
{
@@ -122,11 +119,16 @@ const SeriesHistoryView: FunctionComponent<Props> = () => {
const original = row.original;
const { sonarrEpisodeId, sonarrSeriesId } = original;
+ const { mutateAsync } = useEpisodeAddBlacklist();
return (
<BlacklistButton
history={original}
promise={(form) =>
- EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
+ mutateAsync({
+ seriesId: sonarrSeriesId,
+ episodeId: sonarrEpisodeId,
+ form,
+ })
}
></BlacklistButton>
);
@@ -136,13 +138,10 @@ const SeriesHistoryView: FunctionComponent<Props> = () => {
[]
);
+ const query = useEpisodeHistoryPagination();
+
return (
- <HistoryGenericView
- type="series"
- state={series}
- loader={loader}
- columns={columns}
- ></HistoryGenericView>
+ <HistoryView name="Series" query={query} columns={columns}></HistoryView>
);
};
diff --git a/frontend/src/pages/History/Statistics/index.tsx b/frontend/src/pages/History/Statistics/index.tsx
new file mode 100644
index 000000000..e1875c2f1
--- /dev/null
+++ b/frontend/src/pages/History/Statistics/index.tsx
@@ -0,0 +1,126 @@
+import { useHistoryStats, useLanguages, useSystemProviders } from "apis/hooks";
+import {
+ ContentHeader,
+ LanguageSelector,
+ QueryOverlay,
+ Selector,
+} from "components";
+import { merge } from "lodash";
+import React, { FunctionComponent, useMemo, useState } from "react";
+import { Col, Container } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Legend,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { actionOptions, timeFrameOptions } from "./options";
+
+const SelectorContainer: FunctionComponent = ({ children }) => (
+ <Col xs={6} lg={3} className="p-1">
+ {children}
+ </Col>
+);
+
+const HistoryStats: FunctionComponent = () => {
+ const { data: languages } = useLanguages(true);
+
+ const { data: providers } = useSystemProviders(true);
+
+ const providerOptions = useMemo<SelectorOption<System.Provider>[]>(
+ () => providers?.map((value) => ({ label: value.name, value })) ?? [],
+ [providers]
+ );
+
+ const [timeFrame, setTimeFrame] = useState<History.TimeFrameOptions>("month");
+ const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
+ const [lang, setLanguage] = useState<Nullable<Language.Info>>(null);
+ const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
+
+ const stats = useHistoryStats(timeFrame, action, provider, lang);
+ const { data } = stats;
+
+ const convertedData = useMemo(() => {
+ if (data) {
+ const movies = data.movies.map((v) => ({
+ date: v.date,
+ movies: v.count,
+ }));
+ const series = data.series.map((v) => ({
+ date: v.date,
+ series: v.count,
+ }));
+ const result = merge(movies, series);
+ return result;
+ } else {
+ return [];
+ }
+ }, [data]);
+
+ return (
+ // TODO: Responsive
+ <Container fluid className="vh-75">
+ <Helmet>
+ <title>History Statistics - Bazarr</title>
+ </Helmet>
+ <QueryOverlay result={stats}>
+ <React.Fragment>
+ <ContentHeader scroll={false}>
+ <SelectorContainer>
+ <Selector
+ placeholder="Time..."
+ options={timeFrameOptions}
+ value={timeFrame}
+ onChange={(v) => setTimeFrame(v ?? "month")}
+ ></Selector>
+ </SelectorContainer>
+ <SelectorContainer>
+ <Selector
+ placeholder="Action..."
+ clearable
+ options={actionOptions}
+ value={action}
+ onChange={setAction}
+ ></Selector>
+ </SelectorContainer>
+ <SelectorContainer>
+ <Selector
+ placeholder="Provider..."
+ clearable
+ options={providerOptions}
+ value={provider}
+ onChange={setProvider}
+ ></Selector>
+ </SelectorContainer>
+ <SelectorContainer>
+ <LanguageSelector
+ clearable
+ options={languages ?? []}
+ value={lang}
+ onChange={setLanguage}
+ ></LanguageSelector>
+ </SelectorContainer>
+ </ContentHeader>
+ <ResponsiveContainer height="100%">
+ <BarChart data={convertedData}>
+ <CartesianGrid strokeDasharray="4 2"></CartesianGrid>
+ <XAxis dataKey="date"></XAxis>
+ <YAxis allowDecimals={false}></YAxis>
+ <Tooltip></Tooltip>
+ <Legend verticalAlign="top"></Legend>
+ <Bar name="Series" dataKey="series" fill="#2493B6"></Bar>
+ <Bar name="Movies" dataKey="movies" fill="#FFC22F"></Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </React.Fragment>
+ </QueryOverlay>
+ </Container>
+ );
+};
+
+export default HistoryStats;
diff --git a/frontend/src/History/Statistics/options.ts b/frontend/src/pages/History/Statistics/options.ts
index 5cdd63478..49d2bbe59 100644
--- a/frontend/src/History/Statistics/options.ts
+++ b/frontend/src/pages/History/Statistics/options.ts
@@ -13,7 +13,7 @@ export const actionOptions: SelectorOption<History.ActionOptions>[] = [
},
];
-export const timeframeOptions: SelectorOption<History.TimeframeOptions>[] = [
+export const timeFrameOptions: SelectorOption<History.TimeFrameOptions>[] = [
{
label: "Last Week",
value: "week",
diff --git a/frontend/src/special-pages/LaunchError.tsx b/frontend/src/pages/LaunchError.tsx
index 2b7db3400..80633e926 100644
--- a/frontend/src/special-pages/LaunchError.tsx
+++ b/frontend/src/pages/LaunchError.tsx
@@ -2,7 +2,7 @@ import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { Alert, Button, Container } from "react-bootstrap";
-import { Reload } from "../utilities";
+import { Reload } from "utilities";
interface Props {
children: string;
diff --git a/frontend/src/DisplayItem/MovieDetail/index.tsx b/frontend/src/pages/Movies/Details/index.tsx
index 758817d52..02637dc08 100644
--- a/frontend/src/DisplayItem/MovieDetail/index.tsx
+++ b/frontend/src/pages/Movies/Details/index.tsx
@@ -7,15 +7,14 @@ import {
faUser,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
-import React, { FunctionComponent, useState } from "react";
-import { Alert, Container, Row } from "react-bootstrap";
-import { Helmet } from "react-helmet";
-import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
-import { dispatchTask } from "../../@modules/task";
-import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
-import { createTask } from "../../@modules/task/utilities";
-import { useMovieBy, useProfileBy } from "../../@redux/hooks";
-import { MoviesApi, ProvidersApi } from "../../apis";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useDownloadMovieSubtitles, useIsMovieActionRunning } from "apis/hooks";
+import {
+ useMovieAction,
+ useMovieById,
+ useMovieModification,
+} from "apis/hooks/movies";
import {
ContentHeader,
ItemEditorModal,
@@ -24,24 +23,17 @@ import {
MovieUploadModal,
SubtitleToolModal,
useShowModal,
-} from "../../components";
-import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
-import { RouterEmptyPath } from "../../special-pages/404";
-import { useOnLoadedOnce } from "../../utilities";
-import ItemOverview from "../generic/ItemOverview";
+} from "components";
+import ItemOverview from "components/ItemOverview";
+import { ManualSearchModal } from "components/modals/ManualSearchModal";
+import { RouterEmptyPath } from "pages/404";
+import React, { FunctionComponent, useCallback } from "react";
+import { Alert, Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
+import { useLanguageProfileBy } from "utilities/languages";
import Table from "./table";
-const download = (item: Item.Movie, result: SearchResultType) => {
- const { language, hearing_impaired, forced, provider, subtitle } = result;
- return ProvidersApi.downloadMovieSubtitle(item.radarrId, {
- language,
- hi: hearing_impaired,
- forced,
- provider,
- subtitle,
- });
-};
-
interface Params {
id: string;
}
@@ -50,37 +42,57 @@ interface Props extends RouteComponentProps<Params> {}
const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
const id = Number.parseInt(match.params.id);
- const movie = useMovieBy(id);
- const item = movie.content;
+ const { data: movie, isFetched } = useMovieById(id);
- const profile = useProfileBy(movie.content?.profileId);
+ const profile = useLanguageProfileBy(movie?.profileId);
const showModal = useShowModal();
- const [valid, setValid] = useState(true);
+ const { mutateAsync } = useMovieModification();
+ const { mutateAsync: action } = useMovieAction();
+ const { mutateAsync: downloadAsync } = useDownloadMovieSubtitles();
- const hasTask = useIsAnyTaskRunningWithId([id]);
+ const download = useCallback(
+ (item: Item.Movie, result: SearchResultType) => {
+ const {
+ language,
+ hearing_impaired: hi,
+ forced,
+ provider,
+ subtitle,
+ } = result;
+ const { radarrId } = item;
+
+ return downloadAsync({
+ radarrId,
+ form: {
+ language,
+ hi,
+ forced,
+ provider,
+ subtitle,
+ },
+ });
+ },
+ [downloadAsync]
+ );
- useOnLoadedOnce(() => {
- if (movie.content === null) {
- setValid(false);
- }
- }, movie);
+ const hasTask = useIsMovieActionRunning();
- if (isNaN(id) || !valid) {
+ if (isNaN(id) || (isFetched && !movie)) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
- if (!item) {
+ if (!movie) {
return <LoadingIndicator></LoadingIndicator>;
}
- const allowEdit = item.profileId !== undefined;
+ const allowEdit = movie.profileId !== undefined;
return (
<Container fluid>
<Helmet>
- <title>{item.title} - Bazarr (Movies)</title>
+ <title>{movie.title} - Bazarr (Movies)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Group pos="start">
@@ -88,12 +100,10 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
icon={faSync}
disabled={hasTask}
onClick={() => {
- const task = createTask(
- item.title,
- id,
- MoviesApi.action.bind(MoviesApi),
- { action: "scan-disk", radarrid: id }
- );
+ const task = createTask(movie.title, id, action, {
+ action: "scan-disk",
+ radarrid: id,
+ });
dispatchTask("Scanning Disk...", [task], "Scanning...");
}}
>
@@ -101,17 +111,12 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
</ContentHeader.Button>
<ContentHeader.Button
icon={faSearch}
- disabled={item.profileId === null}
+ disabled={movie.profileId === null}
onClick={() => {
- const task = createTask(
- item.title,
- id,
- MoviesApi.action.bind(MoviesApi),
- {
- action: "search-missing",
- radarrid: id,
- }
- );
+ const task = createTask(movie.title, id, action, {
+ action: "search-missing",
+ radarrid: id,
+ });
dispatchTask("Searching subtitles...", [task], "Searching...");
}}
>
@@ -119,21 +124,21 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
</ContentHeader.Button>
<ContentHeader.Button
icon={faUser}
- disabled={item.profileId === null || hasTask}
- onClick={() => showModal<Item.Movie>("manual-search", item)}
+ disabled={movie.profileId === null || hasTask}
+ onClick={() => showModal<Item.Movie>("manual-search", movie)}
>
Manual
</ContentHeader.Button>
<ContentHeader.Button
icon={faHistory}
- onClick={() => showModal("history", item)}
+ onClick={() => showModal("history", movie)}
>
History
</ContentHeader.Button>
<ContentHeader.Button
icon={faToolbox}
disabled={hasTask}
- onClick={() => showModal("tools", [item])}
+ onClick={() => showModal("tools", [movie])}
>
Tools
</ContentHeader.Button>
@@ -141,16 +146,16 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
<ContentHeader.Group pos="end">
<ContentHeader.Button
- disabled={!allowEdit || item.profileId === null || hasTask}
+ disabled={!allowEdit || movie.profileId === null || hasTask}
icon={faCloudUploadAlt}
- onClick={() => showModal("upload", item)}
+ onClick={() => showModal("upload", movie)}
>
Upload
</ContentHeader.Button>
<ContentHeader.Button
icon={faWrench}
disabled={hasTask}
- onClick={() => showModal("edit", item)}
+ onClick={() => showModal("edit", movie)}
>
Edit Movie
</ContentHeader.Button>
@@ -167,15 +172,12 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
</Alert>
</Row>
<Row>
- <ItemOverview item={item} details={[]}></ItemOverview>
+ <ItemOverview item={movie} details={[]}></ItemOverview>
</Row>
<Row>
- <Table movie={item} profile={profile} disabled={hasTask}></Table>
+ <Table movie={movie} profile={profile} disabled={hasTask}></Table>
</Row>
- <ItemEditorModal
- modalKey="edit"
- submit={(form) => MoviesApi.modify(form)}
- ></ItemEditorModal>
+ <ItemEditorModal modalKey="edit" submit={mutateAsync}></ItemEditorModal>
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
<MovieHistoryModal modalKey="history" size="lg"></MovieHistoryModal>
<MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal>
diff --git a/frontend/src/DisplayItem/MovieDetail/table.tsx b/frontend/src/pages/Movies/Details/table.tsx
index 082565510..6187731c1 100644
--- a/frontend/src/DisplayItem/MovieDetail/table.tsx
+++ b/frontend/src/pages/Movies/Details/table.tsx
@@ -1,13 +1,13 @@
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useShowOnlyDesired } from "@redux/hooks";
+import { useMovieSubtitleModification } from "apis/hooks";
+import { AsyncButton, LanguageText, SimpleTable } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Column } from "react-table";
-import { useProfileItemsToLanguages } from "../../@redux/hooks";
-import { useShowOnlyDesired } from "../../@redux/hooks/site";
-import { MoviesApi } from "../../apis";
-import { AsyncButton, LanguageText, SimpleTable } from "../../components";
-import { filterSubtitleBy } from "../../utilities";
+import { filterSubtitleBy } from "utilities";
+import { useProfileItemsToLanguages } from "utilities/languages";
const missingText = "Missing Subtitles";
@@ -59,18 +59,27 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => {
{
accessor: "code2",
Cell: (row) => {
- const { original } = row.row;
- if (original.path === null || original.path.length === 0) {
+ const {
+ original: { code2, hi, forced, path },
+ } = row.row;
+
+ const { download, remove } = useMovieSubtitleModification();
+ const { radarrId } = movie;
+
+ if (path === null || path.length === 0) {
return null;
- } else if (original.path === missingText) {
+ } else if (path === missingText) {
return (
<AsyncButton
disabled={disabled}
promise={() =>
- MoviesApi.downloadSubtitles(movie.radarrId, {
- language: original.code2,
- hi: original.hi,
- forced: original.forced,
+ download.mutateAsync({
+ radarrId,
+ form: {
+ language: code2,
+ hi,
+ forced,
+ },
})
}
variant="light"
@@ -86,11 +95,14 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => {
variant="light"
size="sm"
promise={() =>
- MoviesApi.deleteSubtitles(movie.radarrId, {
- language: original.code2,
- hi: original.hi,
- forced: original.forced,
- path: original.path ?? "",
+ remove.mutateAsync({
+ radarrId,
+ form: {
+ language: code2,
+ hi,
+ forced,
+ path,
+ },
})
}
>
diff --git a/frontend/src/DisplayItem/Movies/index.tsx b/frontend/src/pages/Movies/index.tsx
index 4036aa6cb..b142c21b5 100644
--- a/frontend/src/DisplayItem/Movies/index.tsx
+++ b/frontend/src/pages/Movies/index.tsx
@@ -1,24 +1,28 @@
import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ useLanguageProfiles,
+ useMovieModification,
+ useMovies,
+ useMoviesPagination,
+} from "apis/hooks";
+import { ActionBadge, LanguageText, TextPopover } from "components";
+import ItemView from "components/views/ItemView";
import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { movieUpdateAll, movieUpdateByRange } from "../../@redux/actions";
-import { useLanguageProfiles, useMovieEntities } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { MoviesApi } from "../../apis";
-import { ActionBadge, LanguageText, TextPopover } from "../../components";
-import { BuildKey } from "../../utilities";
-import BaseItemView from "../generic/BaseItemView";
+import { BuildKey } from "utilities";
interface Props {}
const MovieView: FunctionComponent<Props> = () => {
- const movies = useMovieEntities();
- const loader = useReduxAction(movieUpdateByRange);
- const profiles = useLanguageProfiles();
+ const { data: profiles } = useLanguageProfiles();
+ const mutation = useMovieModification();
+
+ const query = useMoviesPagination();
+ const full = useMovies();
const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
() => [
@@ -107,14 +111,13 @@ const MovieView: FunctionComponent<Props> = () => {
);
return (
- <BaseItemView
- state={movies}
+ <ItemView
name="Movies"
- loader={loader}
- updateAction={movieUpdateAll}
+ fullQuery={full}
+ query={query}
columns={columns}
- modify={(form) => MoviesApi.modify(form)}
- ></BaseItemView>
+ mutation={mutation}
+ ></ItemView>
);
};
diff --git a/frontend/src/DisplayItem/Series/index.tsx b/frontend/src/pages/Series/index.tsx
index 0afd29e9f..334f86d99 100644
--- a/frontend/src/DisplayItem/Series/index.tsx
+++ b/frontend/src/pages/Series/index.tsx
@@ -1,22 +1,27 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
+import {
+ useLanguageProfiles,
+ useSeries,
+ useSeriesModification,
+ useSeriesPagination,
+} from "apis/hooks";
+import { ActionBadge } from "components";
+import ItemView from "components/views/ItemView";
import React, { FunctionComponent, useMemo } from "react";
import { Badge, ProgressBar } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { seriesUpdateAll, seriesUpdateByRange } from "../../@redux/actions";
-import { useLanguageProfiles, useSerieEntities } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { SeriesApi } from "../../apis";
-import { ActionBadge } from "../../components";
-import { BuildKey } from "../../utilities";
-import BaseItemView from "../generic/BaseItemView";
+import { BuildKey } from "utilities";
interface Props {}
const SeriesView: FunctionComponent<Props> = () => {
- const series = useSerieEntities();
- const loader = useReduxAction(seriesUpdateByRange);
- const profiles = useLanguageProfiles();
+ const { data: profiles } = useLanguageProfiles();
+ const mutation = useSeriesModification();
+
+ const query = useSeriesPagination();
+ const full = useSeries();
+
const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>(
() => [
{
@@ -107,14 +112,13 @@ const SeriesView: FunctionComponent<Props> = () => {
);
return (
- <BaseItemView
- state={series}
+ <ItemView
name="Series"
- updateAction={seriesUpdateAll}
- loader={loader}
+ fullQuery={full}
+ query={query}
columns={columns}
- modify={(form) => SeriesApi.modify(form)}
- ></BaseItemView>
+ mutation={mutation}
+ ></ItemView>
);
};
diff --git a/frontend/src/Settings/General/index.tsx b/frontend/src/pages/Settings/General/index.tsx
index 090093a2b..25ce22de3 100644
--- a/frontend/src/Settings/General/index.tsx
+++ b/frontend/src/pages/Settings/General/index.tsx
@@ -6,7 +6,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useState } from "react";
import { InputGroup } from "react-bootstrap";
-import { copyToClipboard, Environment, toggleState } from "../../utilities";
+import { copyToClipboard, Environment, toggleState } from "utilities";
import {
Button,
Check,
diff --git a/frontend/src/Settings/General/options.ts b/frontend/src/pages/Settings/General/options.ts
index ee36eba53..ee36eba53 100644
--- a/frontend/src/Settings/General/options.ts
+++ b/frontend/src/pages/Settings/General/options.ts
diff --git a/frontend/src/Settings/Languages/components.tsx b/frontend/src/pages/Settings/Languages/components.tsx
index 05c1793b6..946866878 100644
--- a/frontend/src/Settings/Languages/components.tsx
+++ b/frontend/src/pages/Settings/Languages/components.tsx
@@ -1,6 +1,6 @@
+import { LanguageSelector as CLanguageSelector } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { useEnabledLanguagesContext, useProfilesContext } from ".";
-import { LanguageSelector as CLanguageSelector } from "../../components";
import { BaseInput, Selector, useSingleUpdate } from "../components";
interface LanguageSelectorProps {
diff --git a/frontend/src/Settings/Languages/index.tsx b/frontend/src/pages/Settings/Languages/index.tsx
index 01629bb51..5bb05dfa6 100644
--- a/frontend/src/Settings/Languages/index.tsx
+++ b/frontend/src/pages/Settings/Languages/index.tsx
@@ -1,10 +1,7 @@
+import { useLanguageProfiles, useLanguages } from "apis/hooks";
import { isArray } from "lodash";
import React, { FunctionComponent, useContext } from "react";
-import {
- useEnabledLanguages,
- useLanguageProfiles,
- useLanguages,
-} from "../../@redux/hooks";
+import { useEnabledLanguages } from "utilities/languages";
import {
Check,
CollapseBox,
@@ -50,9 +47,9 @@ export function useProfilesContext() {
interface Props {}
const SettingsLanguagesView: FunctionComponent<Props> = () => {
- const languages = useLanguages();
- const enabled = useEnabledLanguages();
- const profiles = useLanguageProfiles();
+ const { data: languages } = useLanguages();
+ const { data: enabled } = useEnabledLanguages();
+ const { data: profiles } = useLanguageProfiles();
return (
<SettingsProvider title="Languages - Bazarr (Settings)">
@@ -77,7 +74,7 @@ const SettingsLanguagesView: FunctionComponent<Props> = () => {
<Input name="Languages Filter">
<LanguageSelector
settingKey={enabledLanguageKey}
- options={languages}
+ options={languages ?? []}
></LanguageSelector>
</Input>
</Group>
diff --git a/frontend/src/Settings/Languages/modal.tsx b/frontend/src/pages/Settings/Languages/modal.tsx
index 7371267dc..028d9500e 100644
--- a/frontend/src/Settings/Languages/modal.tsx
+++ b/frontend/src/pages/Settings/Languages/modal.tsx
@@ -1,4 +1,14 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import {
+ ActionButton,
+ BaseModal,
+ BaseModalProps,
+ Chips,
+ LanguageSelector,
+ Selector,
+ SimpleTable,
+ useModalInformation,
+} from "components";
import React, {
FunctionComponent,
useCallback,
@@ -8,18 +18,8 @@ import React, {
} from "react";
import { Button, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
+import { BuildKey } from "utilities";
import { useEnabledLanguagesContext } from ".";
-import {
- ActionButton,
- BaseModal,
- BaseModalProps,
- Chips,
- LanguageSelector,
- Selector,
- SimpleTable,
- useModalInformation,
-} from "../../components";
-import { BuildKey } from "../../utilities";
import { Input, Message } from "../components";
import { cutoffOptions } from "./options";
interface Props {
diff --git a/frontend/src/Settings/Languages/options.ts b/frontend/src/pages/Settings/Languages/options.ts
index 01e5da382..01e5da382 100644
--- a/frontend/src/Settings/Languages/options.ts
+++ b/frontend/src/pages/Settings/Languages/options.ts
diff --git a/frontend/src/Settings/Languages/table.tsx b/frontend/src/pages/Settings/Languages/table.tsx
index 10b71ca60..7c5dd0121 100644
--- a/frontend/src/Settings/Languages/table.tsx
+++ b/frontend/src/pages/Settings/Languages/table.tsx
@@ -1,4 +1,5 @@
import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
+import { ActionButton, SimpleTable, useShowModal } from "components";
import { cloneDeep } from "lodash";
import React, {
FunctionComponent,
@@ -9,7 +10,6 @@ import React, {
import { Badge, Button, ButtonGroup } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
import { useEnabledLanguagesContext, useProfilesContext } from ".";
-import { ActionButton, SimpleTable, useShowModal } from "../../components";
import { useSingleUpdate } from "../components";
import { languageProfileKey } from "../keys";
import Modal from "./modal";
diff --git a/frontend/src/Settings/Notifications/components.tsx b/frontend/src/pages/Settings/Notifications/components.tsx
index 84add66ca..fee5611ae 100644
--- a/frontend/src/Settings/Notifications/components.tsx
+++ b/frontend/src/pages/Settings/Notifications/components.tsx
@@ -1,11 +1,4 @@
-import React, {
- FunctionComponent,
- useCallback,
- useMemo,
- useState,
-} from "react";
-import { Button, Col, Container, Form, Row } from "react-bootstrap";
-import { SystemApi } from "../../apis";
+import api from "apis/raw";
import {
AsyncButton,
BaseModal,
@@ -14,8 +7,15 @@ import {
useModalInformation,
useOnModalShow,
useShowModal,
-} from "../../components";
-import { BuildKey } from "../../utilities";
+} from "components";
+import React, {
+ FunctionComponent,
+ useCallback,
+ useMemo,
+ useState,
+} from "react";
+import { Button, Col, Container, Form, Row } from "react-bootstrap";
+import { BuildKey } from "utilities";
import { ColCard, useLatestArray, useUpdateArray } from "../components";
import { notificationsKey } from "../keys";
@@ -79,7 +79,7 @@ const NotificationModal: FunctionComponent<ModalProps & BaseModalProps> = ({
variant="outline-secondary"
promise={() => {
if (current && current.url) {
- return SystemApi.testNotification(current.url);
+ return api.system.testNotification(current.url);
} else {
return null;
}
diff --git a/frontend/src/Settings/Notifications/index.tsx b/frontend/src/pages/Settings/Notifications/index.tsx
index 06585183f..06585183f 100644
--- a/frontend/src/Settings/Notifications/index.tsx
+++ b/frontend/src/pages/Settings/Notifications/index.tsx
diff --git a/frontend/src/Settings/Providers/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx
index 9e005d123..81e270610 100644
--- a/frontend/src/Settings/Providers/components.tsx
+++ b/frontend/src/pages/Settings/Providers/components.tsx
@@ -1,3 +1,10 @@
+import {
+ BaseModal,
+ Selector,
+ useModalInformation,
+ useOnModalShow,
+ useShowModal,
+} from "components";
import { capitalize, isArray, isBoolean } from "lodash";
import React, {
FunctionComponent,
@@ -9,14 +16,7 @@ import React, {
import { Button, Col, Container, Row } from "react-bootstrap";
import { components } from "react-select";
import { SelectComponents } from "react-select/dist/declarations/src/components";
-import {
- BaseModal,
- Selector,
- useModalInformation,
- useOnModalShow,
- useShowModal,
-} from "../../components";
-import { BuildKey, isReactText } from "../../utilities";
+import { BuildKey, isReactText } from "utilities";
import {
Check,
ColCard,
diff --git a/frontend/src/Settings/Providers/index.tsx b/frontend/src/pages/Settings/Providers/index.tsx
index a7b9ef171..a7b9ef171 100644
--- a/frontend/src/Settings/Providers/index.tsx
+++ b/frontend/src/pages/Settings/Providers/index.tsx
diff --git a/frontend/src/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts
index aac5ce6bb..aac5ce6bb 100644
--- a/frontend/src/Settings/Providers/list.ts
+++ b/frontend/src/pages/Settings/Providers/list.ts
diff --git a/frontend/src/Settings/Radarr/index.tsx b/frontend/src/pages/Settings/Radarr/index.tsx
index c0f4807f7..7281983fd 100644
--- a/frontend/src/Settings/Radarr/index.tsx
+++ b/frontend/src/pages/Settings/Radarr/index.tsx
@@ -7,12 +7,12 @@ import {
Group,
Input,
Message,
+ PathMappingTable,
SettingsProvider,
Slider,
Text,
URLTestButton,
} from "../components";
-import { PathMappingTable } from "../components/pathMapper";
import { moviesEnabledKey } from "../keys";
interface Props {}
diff --git a/frontend/src/Settings/Scheduler/index.tsx b/frontend/src/pages/Settings/Scheduler/index.tsx
index 728727fbd..728727fbd 100644
--- a/frontend/src/Settings/Scheduler/index.tsx
+++ b/frontend/src/pages/Settings/Scheduler/index.tsx
diff --git a/frontend/src/Settings/Scheduler/options.ts b/frontend/src/pages/Settings/Scheduler/options.ts
index 7d5f52e9f..7d5f52e9f 100644
--- a/frontend/src/Settings/Scheduler/options.ts
+++ b/frontend/src/pages/Settings/Scheduler/options.ts
diff --git a/frontend/src/Settings/Sonarr/index.tsx b/frontend/src/pages/Settings/Sonarr/index.tsx
index 75ae9b3a1..c1e26e2dc 100644
--- a/frontend/src/Settings/Sonarr/index.tsx
+++ b/frontend/src/pages/Settings/Sonarr/index.tsx
@@ -7,13 +7,13 @@ import {
Group,
Input,
Message,
+ PathMappingTable,
Selector,
SettingsProvider,
Slider,
Text,
URLTestButton,
} from "../components";
-import { PathMappingTable } from "../components/pathMapper";
import { seriesEnabledKey } from "../keys";
import { seriesTypeOptions } from "../options";
diff --git a/frontend/src/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx
index 092c8fefb..aab391200 100644
--- a/frontend/src/Settings/Subtitles/index.tsx
+++ b/frontend/src/pages/Settings/Subtitles/index.tsx
@@ -162,7 +162,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
options={adaptiveSearchingDelayOption}
></Selector>
<Message>
- In order to reduce search frequency, how many weeks must Bazarr wait after initial search.
+ In order to reduce search frequency, how many weeks must Bazarr
+ wait after initial search.
</Message>
</Input>
<Input>
diff --git a/frontend/src/Settings/Subtitles/options.ts b/frontend/src/pages/Settings/Subtitles/options.ts
index fe6adaa2a..fe6adaa2a 100644
--- a/frontend/src/Settings/Subtitles/options.ts
+++ b/frontend/src/pages/Settings/Subtitles/options.ts
diff --git a/frontend/src/Settings/UI/index.tsx b/frontend/src/pages/Settings/UI/index.tsx
index 6f254ab46..74e30fada 100644
--- a/frontend/src/Settings/UI/index.tsx
+++ b/frontend/src/pages/Settings/UI/index.tsx
@@ -1,5 +1,5 @@
import React, { FunctionComponent } from "react";
-import { uiPageSizeKey, usePageSize } from "../../@storage/local";
+import { uiPageSizeKey, usePageSize } from "utilities/storage";
import { Group, Input, Selector, SettingsProvider } from "../components";
import { pageSizeOptions } from "./options";
diff --git a/frontend/src/Settings/UI/options.ts b/frontend/src/pages/Settings/UI/options.ts
index 9dd6f0d46..9dd6f0d46 100644
--- a/frontend/src/Settings/UI/options.ts
+++ b/frontend/src/pages/Settings/UI/options.ts
diff --git a/frontend/src/Settings/components/collapse.tsx b/frontend/src/pages/Settings/components/collapse.tsx
index 0f9398481..0f9398481 100644
--- a/frontend/src/Settings/components/collapse.tsx
+++ b/frontend/src/pages/Settings/components/collapse.tsx
diff --git a/frontend/src/Settings/components/container.tsx b/frontend/src/pages/Settings/components/container.tsx
index 4808f12c0..4808f12c0 100644
--- a/frontend/src/Settings/components/container.tsx
+++ b/frontend/src/pages/Settings/components/container.tsx
diff --git a/frontend/src/Settings/components/forms.tsx b/frontend/src/pages/Settings/components/forms.tsx
index 6ffe92f30..b4587f127 100644
--- a/frontend/src/Settings/components/forms.tsx
+++ b/frontend/src/pages/Settings/components/forms.tsx
@@ -1,3 +1,11 @@
+import {
+ Chips as CChips,
+ ChipsProps as CChipsProps,
+ Selector as CSelector,
+ SelectorProps as CSelectorProps,
+ Slider as CSlider,
+ SliderProps as CSliderProps,
+} from "components";
import { isArray, isBoolean, isNull, isNumber, isString } from "lodash";
import React, { FunctionComponent, useEffect } from "react";
import {
@@ -5,16 +13,8 @@ import {
ButtonProps as BSButtonProps,
Form,
} from "react-bootstrap";
+import { isReactText } from "utilities";
import { useCollapse, useLatest } from ".";
-import {
- Chips as CChips,
- ChipsProps as CChipsProps,
- Selector as CSelector,
- SelectorProps as CSelectorProps,
- Slider as CSlider,
- SliderProps as CSliderProps,
-} from "../../components";
-import { isReactText } from "../../utilities";
import { OverrideFuncType, useSingleUpdate } from "./hooks";
export const Message: FunctionComponent<{
diff --git a/frontend/src/Settings/components/hooks.ts b/frontend/src/pages/Settings/components/hooks.ts
index bdb98b5a0..5f1441641 100644
--- a/frontend/src/Settings/components/hooks.ts
+++ b/frontend/src/pages/Settings/components/hooks.ts
@@ -1,7 +1,7 @@
+import { useSystemSettings } from "apis/hooks";
import { isArray, uniqBy } from "lodash";
import { useCallback, useContext, useMemo } from "react";
-import { useSystemSettings } from "../../@redux/hooks";
-import { log } from "../../utilities/logger";
+import { log } from "utilities/logger";
import { StagedChangesContext } from "./provider";
export function useStagedValues(): LooseObject {
@@ -51,7 +51,7 @@ export function useExtract<T>(
validate: ValidateFuncType<T>,
override?: OverrideFuncType<T>
): Readonly<Nullable<T>> {
- const settings = useSystemSettings().content!;
+ const { data: settings } = useSystemSettings();
const extractValue = useMemo(() => {
let value: Nullable<T> = null;
diff --git a/frontend/src/Settings/components/index.tsx b/frontend/src/pages/Settings/components/index.tsx
index 8105c48e8..fbec4221f 100644
--- a/frontend/src/Settings/components/index.tsx
+++ b/frontend/src/pages/Settings/components/index.tsx
@@ -1,7 +1,7 @@
+import api from "apis/raw";
import { isBoolean, isNumber, isString } from "lodash";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Button } from "react-bootstrap";
-import { UtilsApi } from "../../apis";
import { useLatest } from "./hooks";
export const URLTestButton: FunctionComponent<{
@@ -36,8 +36,9 @@ export const URLTestButton: FunctionComponent<{
request.url += "/";
}
- UtilsApi.urlTest(request.protocol, request.url, request.params).then(
- (result) => {
+ api.utils
+ .urlTest(request.protocol, request.url, request.params)
+ .then((result) => {
if (result.status) {
setTitle(`Version: ${result.version}`);
setVar("success");
@@ -45,8 +46,7 @@ export const URLTestButton: FunctionComponent<{
setTitle(result.error);
setVar("danger");
}
- }
- );
+ });
}
}, [address, port, url, apikey, ssl]);
@@ -67,5 +67,6 @@ export { default as CollapseBox } from "./collapse";
export * from "./container";
export * from "./forms";
export * from "./hooks";
+export * from "./pathMapper";
export * from "./provider";
export { default as SettingsProvider } from "./provider";
diff --git a/frontend/src/Settings/components/pathMapper.tsx b/frontend/src/pages/Settings/components/pathMapper.tsx
index b94d1366f..19564e7ce 100644
--- a/frontend/src/Settings/components/pathMapper.tsx
+++ b/frontend/src/pages/Settings/components/pathMapper.tsx
@@ -1,11 +1,10 @@
import { faArrowCircleRight, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ActionButton, FileBrowser, SimpleTable } from "components";
import { capitalize, isArray, isBoolean } from "lodash";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Button } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
-import { FilesApi } from "../../apis";
-import { ActionButton, FileBrowser, SimpleTable } from "../../components";
import {
moviesEnabledKey,
pathMappingsKey,
@@ -73,14 +72,6 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
[items]
);
- const request = useMemo(() => {
- if (type === "sonarr") {
- return (path: string) => FilesApi.sonarr(path);
- } else {
- return (path: string) => FilesApi.radarr(path);
- }
- }, [type]);
-
const updateCell = useCallback<TableUpdater<PathMappingItem>>(
(row, item?: PathMappingItem) => {
const newItems = [...data];
@@ -102,8 +93,8 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
Cell: ({ value, row, update }) => (
<FileBrowser
drop="up"
+ type={type}
defaultValue={value}
- load={request}
onChange={(path) => {
const newItem = { ...row.original };
newItem.from = path;
@@ -126,7 +117,7 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
<FileBrowser
drop="up"
defaultValue={value}
- load={(path) => FilesApi.bazarr(path)}
+ type="bazarr"
onChange={(path) => {
const newItem = { ...row.original };
newItem.to = path;
@@ -148,7 +139,7 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
),
},
],
- [type, request]
+ [type]
);
if (enabled) {
diff --git a/frontend/src/Settings/components/provider.tsx b/frontend/src/pages/Settings/components/provider.tsx
index c895b47fb..3f364f1e2 100644
--- a/frontend/src/Settings/components/provider.tsx
+++ b/frontend/src/pages/Settings/components/provider.tsx
@@ -1,4 +1,6 @@
import { faSave } from "@fortawesome/free-solid-svg-icons";
+import { useSettingsMutation, useSystemSettings } from "apis/hooks";
+import { ContentHeader, LoadingIndicator } from "components";
import { merge } from "lodash";
import React, {
FunctionComponent,
@@ -10,12 +12,8 @@ import React, {
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Prompt } from "react-router";
-import { useDidUpdate } from "rooks";
-import { useSystemSettings } from "../../@redux/hooks";
-import { useUpdateLocalStorage } from "../../@storage/local";
-import { SystemApi } from "../../apis";
-import { ContentHeader } from "../../components";
-import { log } from "../../utilities/logger";
+import { log } from "utilities/logger";
+import { useUpdateLocalStorage } from "utilities/storage";
import {
enabledLanguageKey,
languageProfileKey,
@@ -56,24 +54,25 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
const updateStorage = useUpdateLocalStorage();
const [stagedChange, setChange] = useState<LooseObject>({});
- const [updating, setUpdating] = useState(false);
const [dispatcher, setDispatcher] = useState<SettingDispatcher>({});
- const settings = useSystemSettings();
- useDidUpdate(() => {
- // Will be updated by websocket
- if (settings.state !== "loading") {
+ const { isLoading, isRefetching } = useSystemSettings();
+ const { mutate, isLoading: isMutating } = useSettingsMutation();
+
+ useEffect(() => {
+ if (isRefetching === false) {
setChange({});
- setUpdating(false);
}
- }, [settings.state]);
+ }, [isRefetching]);
- const saveSettings = useCallback((settings: LooseObject) => {
- submitHooks(settings);
- setUpdating(true);
- log("info", "submitting settings", settings);
- SystemApi.setSettings(settings);
- }, []);
+ const saveSettings = useCallback(
+ (settings: LooseObject) => {
+ submitHooks(settings);
+ log("info", "submitting settings", settings);
+ mutate(settings);
+ },
+ [mutate]
+ );
const saveLocalStorage = useCallback(
(settings: LooseObject) => {
@@ -128,6 +127,10 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
defaultDispatcher(lostValues);
}, [stagedChange, dispatcher, defaultDispatcher]);
+ if (isLoading) {
+ return <LoadingIndicator></LoadingIndicator>;
+ }
+
return (
<Container fluid>
<Helmet>
@@ -140,7 +143,7 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
<ContentHeader>
<ContentHeader.Button
icon={faSave}
- updating={updating}
+ updating={isMutating}
disabled={Object.keys(stagedChange).length === 0}
onClick={submit}
>
diff --git a/frontend/src/Settings/components/style.scss b/frontend/src/pages/Settings/components/style.scss
index 83b21eb4e..83b21eb4e 100644
--- a/frontend/src/Settings/components/style.scss
+++ b/frontend/src/pages/Settings/components/style.scss
diff --git a/frontend/src/Settings/keys.ts b/frontend/src/pages/Settings/keys.ts
index a8ab17a5b..a8ab17a5b 100644
--- a/frontend/src/Settings/keys.ts
+++ b/frontend/src/pages/Settings/keys.ts
diff --git a/frontend/src/Settings/options.ts b/frontend/src/pages/Settings/options.ts
index 58ff5f490..58ff5f490 100644
--- a/frontend/src/Settings/options.ts
+++ b/frontend/src/pages/Settings/options.ts
diff --git a/frontend/src/pages/System/Logs/index.tsx b/frontend/src/pages/System/Logs/index.tsx
new file mode 100644
index 000000000..2f835b44b
--- /dev/null
+++ b/frontend/src/pages/System/Logs/index.tsx
@@ -0,0 +1,55 @@
+import { faDownload, faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { useDeleteLogs, useSystemLogs } from "apis/hooks";
+import { ContentHeader, QueryOverlay } from "components";
+import React, { FunctionComponent, useCallback } from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Environment } from "utilities";
+import Table from "./table";
+
+interface Props {}
+
+const SystemLogsView: FunctionComponent<Props> = () => {
+ const logs = useSystemLogs();
+ const { isFetching, data, refetch } = logs;
+
+ const { mutate, isLoading } = useDeleteLogs();
+
+ const download = useCallback(() => {
+ window.open(`${Environment.baseUrl}/bazarr.log`);
+ }, []);
+
+ return (
+ <QueryOverlay result={logs}>
+ <Container fluid>
+ <Helmet>
+ <title>Logs - Bazarr (System)</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.Button
+ updating={isFetching}
+ icon={faSync}
+ onClick={() => refetch()}
+ >
+ Refresh
+ </ContentHeader.Button>
+ <ContentHeader.Button icon={faDownload} onClick={download}>
+ Download
+ </ContentHeader.Button>
+ <ContentHeader.Button
+ updating={isLoading}
+ icon={faTrash}
+ onClick={() => mutate()}
+ >
+ Empty
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <Table logs={data ?? []}></Table>
+ </Row>
+ </Container>
+ </QueryOverlay>
+ );
+};
+
+export default SystemLogsView;
diff --git a/frontend/src/System/Logs/modal.tsx b/frontend/src/pages/System/Logs/modal.tsx
index cce24cb46..c06241cad 100644
--- a/frontend/src/System/Logs/modal.tsx
+++ b/frontend/src/pages/System/Logs/modal.tsx
@@ -1,5 +1,5 @@
+import { BaseModal, BaseModalProps, useModalPayload } from "components";
import React, { FunctionComponent, useMemo } from "react";
-import { BaseModal, BaseModalProps, useModalPayload } from "../../components";
interface Props extends BaseModalProps {}
diff --git a/frontend/src/System/Logs/table.tsx b/frontend/src/pages/System/Logs/table.tsx
index 9805040cd..b8eace161 100644
--- a/frontend/src/System/Logs/table.tsx
+++ b/frontend/src/pages/System/Logs/table.tsx
@@ -8,10 +8,10 @@ import {
faQuestion,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ActionButton, PageTable, useShowModal } from "components";
import { isUndefined } from "lodash";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Column, Row } from "react-table";
-import { ActionButton, PageTable, useShowModal } from "../../components";
import SystemLogModal from "./modal";
interface Props {
diff --git a/frontend/src/pages/System/Providers/index.tsx b/frontend/src/pages/System/Providers/index.tsx
new file mode 100644
index 000000000..f6b3b14fc
--- /dev/null
+++ b/frontend/src/pages/System/Providers/index.tsx
@@ -0,0 +1,48 @@
+import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { useResetProvider, useSystemProviders } from "apis/hooks";
+import { ContentHeader, QueryOverlay } from "components";
+import React, { FunctionComponent } from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import Table from "./table";
+
+interface Props {}
+
+const SystemProvidersView: FunctionComponent<Props> = () => {
+ const providers = useSystemProviders();
+
+ const { isFetching, data, refetch } = providers;
+
+ const { mutate: reset, isLoading: isResetting } = useResetProvider();
+
+ return (
+ <QueryOverlay result={providers}>
+ <Container fluid>
+ <Helmet>
+ <title>Providers - Bazarr (System)</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.Button
+ updating={isFetching}
+ icon={faSync}
+ onClick={() => refetch()}
+ >
+ Refresh
+ </ContentHeader.Button>
+ <ContentHeader.Button
+ icon={faTrash}
+ updating={isResetting}
+ onClick={() => reset()}
+ >
+ Reset
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <Table providers={data ?? []}></Table>
+ </Row>
+ </Container>
+ </QueryOverlay>
+ );
+};
+
+export default SystemProvidersView;
diff --git a/frontend/src/System/Providers/table.tsx b/frontend/src/pages/System/Providers/table.tsx
index 01ceadee8..bf2168a5b 100644
--- a/frontend/src/System/Providers/table.tsx
+++ b/frontend/src/pages/System/Providers/table.tsx
@@ -1,6 +1,6 @@
+import { SimpleTable } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
-import { SimpleTable } from "../../components";
interface Props {
providers: readonly System.Provider[];
diff --git a/frontend/src/System/Releases/index.tsx b/frontend/src/pages/System/Releases/index.tsx
index 9c6f1cb93..8863aed6a 100644
--- a/frontend/src/System/Releases/index.tsx
+++ b/frontend/src/pages/System/Releases/index.tsx
@@ -1,14 +1,15 @@
+import { useSystemReleases } from "apis/hooks";
+import { QueryOverlay } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Badge, Card, Col, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
-import { useSystemReleases } from "../../@redux/hooks";
-import { AsyncOverlay } from "../../components";
-import { BuildKey } from "../../utilities";
+import { BuildKey } from "utilities";
interface Props {}
const SystemReleasesView: FunctionComponent<Props> = () => {
const releases = useSystemReleases();
+ const { data } = releases;
return (
<Container fluid className="px-3 py-4 bg-light">
@@ -16,19 +17,15 @@ const SystemReleasesView: FunctionComponent<Props> = () => {
<title>Releases - Bazarr (System)</title>
</Helmet>
<Row>
- <AsyncOverlay ctx={releases}>
- {({ content }) => {
- return (
- <React.Fragment>
- {content?.map((v, idx) => (
- <Col xs={12} key={BuildKey(idx, v.date)}>
- <InfoElement {...v}></InfoElement>
- </Col>
- ))}
- </React.Fragment>
- );
- }}
- </AsyncOverlay>
+ <QueryOverlay result={releases}>
+ <React.Fragment>
+ {data?.map((v, idx) => (
+ <Col xs={12} key={BuildKey(idx, v.date)}>
+ <InfoElement {...v}></InfoElement>
+ </Col>
+ ))}
+ </React.Fragment>
+ </QueryOverlay>
</Row>
</Container>
);
@@ -67,9 +64,11 @@ const InfoElement: FunctionComponent<ReleaseInfo> = ({
<Card.Body>
<Card.Text>
From newest to oldest:
- {infos.map((v, idx) => (
- <li key={idx}>{v}</li>
- ))}
+ <div className="mx-4">
+ {infos.map((v, idx) => (
+ <li key={idx}>{v}</li>
+ ))}
+ </div>
</Card.Text>
</Card.Body>
</Card>
diff --git a/frontend/src/System/Status/index.tsx b/frontend/src/pages/System/Status/index.tsx
index 1ac6507c3..c5c05b304 100644
--- a/frontend/src/System/Status/index.tsx
+++ b/frontend/src/pages/System/Status/index.tsx
@@ -6,14 +6,14 @@ import {
} from "@fortawesome/free-brands-svg-icons";
import { faPaperPlane } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useSystemHealth, useSystemStatus } from "apis/hooks";
+import { QueryOverlay } from "components";
import moment from "moment";
import React, { FunctionComponent, useState } from "react";
import { Col, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { useIntervalWhen } from "rooks";
-import { useSystemHealth, useSystemStatus } from "../../@redux/hooks";
-import { AsyncOverlay } from "../../components";
-import { GithubRepoRoot } from "../../constants";
+import { GithubRepoRoot } from "utilities/constants";
import "./style.scss";
import Table from "./table";
@@ -69,7 +69,7 @@ interface Props {}
const SystemStatusView: FunctionComponent<Props> = () => {
const health = useSystemHealth();
- const status = useSystemStatus();
+ const { data: status } = useSystemStatus();
const [uptime, setState] = useState<string>();
const [intervalWhenState] = useState(true);
@@ -100,11 +100,9 @@ const SystemStatusView: FunctionComponent<Props> = () => {
</Helmet>
<Row>
<InfoContainer title="Health">
- <AsyncOverlay ctx={health}>
- {({ content }) => {
- return <Table health={content ?? []}></Table>;
- }}
- </AsyncOverlay>
+ <QueryOverlay result={health}>
+ <Table health={health.data ?? []}></Table>
+ </QueryOverlay>
</InfoContainer>
</Row>
<Row>
diff --git a/frontend/src/System/Status/style.scss b/frontend/src/pages/System/Status/style.scss
index b3f9bbe50..b3f9bbe50 100644
--- a/frontend/src/System/Status/style.scss
+++ b/frontend/src/pages/System/Status/style.scss
diff --git a/frontend/src/System/Status/table.tsx b/frontend/src/pages/System/Status/table.tsx
index 254600deb..3e499f5f4 100644
--- a/frontend/src/System/Status/table.tsx
+++ b/frontend/src/pages/System/Status/table.tsx
@@ -1,6 +1,6 @@
+import { SimpleTable } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
-import { SimpleTable } from "../../components";
interface Props {
health: readonly System.Health[];
diff --git a/frontend/src/pages/System/Tasks/index.tsx b/frontend/src/pages/System/Tasks/index.tsx
new file mode 100644
index 000000000..1bb3430f4
--- /dev/null
+++ b/frontend/src/pages/System/Tasks/index.tsx
@@ -0,0 +1,39 @@
+import { faSync } from "@fortawesome/free-solid-svg-icons";
+import { useSystemTasks } from "apis/hooks";
+import { ContentHeader, QueryOverlay } from "components";
+import React, { FunctionComponent } from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import Table from "./table";
+
+interface Props {}
+
+const SystemTasksView: FunctionComponent<Props> = () => {
+ const tasks = useSystemTasks();
+
+ const { isFetching, data, refetch } = tasks;
+
+ return (
+ <QueryOverlay result={tasks}>
+ <Container fluid>
+ <Helmet>
+ <title>Tasks - Bazarr (System)</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.Button
+ updating={isFetching}
+ icon={faSync}
+ onClick={() => refetch()}
+ >
+ Refresh
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <Table tasks={data ?? []}></Table>
+ </Row>
+ </Container>
+ </QueryOverlay>
+ );
+};
+
+export default SystemTasksView;
diff --git a/frontend/src/System/Tasks/table.tsx b/frontend/src/pages/System/Tasks/table.tsx
index 8ecf139a6..e7a9bc42e 100644
--- a/frontend/src/System/Tasks/table.tsx
+++ b/frontend/src/pages/System/Tasks/table.tsx
@@ -1,9 +1,9 @@
import { faSync } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useRunTask } from "apis/hooks";
+import { AsyncButton, SimpleTable } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { Column, useSortBy } from "react-table";
-import { SystemApi } from "../../apis";
-import { AsyncButton, SimpleTable } from "../../components";
interface Props {
tasks: readonly System.Task[];
@@ -31,9 +31,10 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
accessor: "job_running",
Cell: (row) => {
const { job_id } = row.row.original;
+ const { mutateAsync } = useRunTask();
return (
<AsyncButton
- promise={() => SystemApi.runTask(job_id)}
+ promise={() => mutateAsync(job_id)}
variant="light"
size="sm"
disabled={row.value}
diff --git a/frontend/src/special-pages/UIError.tsx b/frontend/src/pages/UIError.tsx
index 39bbd047f..ba87bb588 100644
--- a/frontend/src/special-pages/UIError.tsx
+++ b/frontend/src/pages/UIError.tsx
@@ -2,8 +2,8 @@ import { faDizzy } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { Button, Container } from "react-bootstrap";
-import { GithubRepoRoot } from "../constants";
-import { Reload } from "../utilities";
+import { Reload } from "utilities";
+import { GithubRepoRoot } from "utilities/constants";
interface Props {
error: Error;
diff --git a/frontend/src/Wanted/Movies/index.tsx b/frontend/src/pages/Wanted/Movies/index.tsx
index b9bd68250..1acf379c6 100644
--- a/frontend/src/Wanted/Movies/index.tsx
+++ b/frontend/src/pages/Wanted/Movies/index.tsx
@@ -1,29 +1,21 @@
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import React, { FunctionComponent, useCallback, useMemo } from "react";
+import {
+ useMovieAction,
+ useMovieSubtitleModification,
+ useMovieWantedPagination,
+} from "apis/hooks";
+import { AsyncButton, LanguageText } from "components";
+import WantedView from "components/views/WantedView";
+import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { movieUpdateWantedByRange } from "../../@redux/actions";
-import { useWantedMovies } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { MoviesApi } from "../../apis";
-import { AsyncButton, LanguageText } from "../../components";
-import { BuildKey } from "../../utilities";
-import GenericWantedView from "../generic";
+import { BuildKey } from "utilities";
interface Props {}
const WantedMoviesView: FunctionComponent<Props> = () => {
- const wantedMovies = useWantedMovies();
-
- const loader = useReduxAction(movieUpdateWantedByRange);
-
- const searchAll = useCallback(
- () => MoviesApi.action({ action: "search-wanted" }),
- []
- );
-
const columns: Column<Wanted.Movie>[] = useMemo<Column<Wanted.Movie>[]>(
() => [
{
@@ -41,10 +33,11 @@ const WantedMoviesView: FunctionComponent<Props> = () => {
{
Header: "Missing",
accessor: "missing_subtitles",
- Cell: ({ row, value, update }) => {
+ Cell: ({ row, value }) => {
const wanted = row.original;
- const hi = wanted.hearing_impaired;
- const movieid = wanted.radarrId;
+ const { hearing_impaired: hi, radarrId } = wanted;
+
+ const { download } = useMovieSubtitleModification();
return value.map((item, idx) => (
<AsyncButton
@@ -53,13 +46,15 @@ const WantedMoviesView: FunctionComponent<Props> = () => {
className="mx-1 mr-2"
variant="secondary"
promise={() =>
- MoviesApi.downloadSubtitles(movieid, {
- language: item.code2,
- hi,
- forced: false,
+ download.mutateAsync({
+ radarrId,
+ form: {
+ language: item.code2,
+ hi,
+ forced: false,
+ },
})
}
- onSuccess={() => update && update(row, movieid)}
>
<LanguageText className="pr-1" text={item}></LanguageText>
<FontAwesomeIcon size="sm" icon={faSearch}></FontAwesomeIcon>
@@ -71,14 +66,16 @@ const WantedMoviesView: FunctionComponent<Props> = () => {
[]
);
+ const { mutateAsync } = useMovieAction();
+ const query = useMovieWantedPagination();
+
return (
- <GenericWantedView
- type="movies"
+ <WantedView
+ name="Movies"
columns={columns}
- state={wantedMovies}
- loader={loader}
- searchAll={searchAll}
- ></GenericWantedView>
+ query={query}
+ searchAll={() => mutateAsync({ action: "search-wanted" })}
+ ></WantedView>
);
};
diff --git a/frontend/src/Wanted/Series/index.tsx b/frontend/src/pages/Wanted/Series/index.tsx
index 065521b86..f4548dc48 100644
--- a/frontend/src/Wanted/Series/index.tsx
+++ b/frontend/src/pages/Wanted/Series/index.tsx
@@ -1,29 +1,21 @@
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import React, { FunctionComponent, useCallback, useMemo } from "react";
+import {
+ useEpisodeSubtitleModification,
+ useEpisodeWantedPagination,
+ useSeriesAction,
+} from "apis/hooks";
+import { AsyncButton, LanguageText } from "components";
+import WantedView from "components/views/WantedView";
+import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
-import { seriesUpdateWantedByRange } from "../../@redux/actions";
-import { useWantedSeries } from "../../@redux/hooks";
-import { useReduxAction } from "../../@redux/hooks/base";
-import { EpisodesApi, SeriesApi } from "../../apis";
-import { AsyncButton, LanguageText } from "../../components";
-import { BuildKey } from "../../utilities";
-import GenericWantedView from "../generic";
+import { BuildKey } from "utilities";
interface Props {}
const WantedSeriesView: FunctionComponent<Props> = () => {
- const series = useWantedSeries();
-
- const loader = useReduxAction(seriesUpdateWantedByRange);
-
- const searchAll = useCallback(
- () => SeriesApi.action({ action: "search-wanted" }),
- []
- );
-
const columns: Column<Wanted.Episode>[] = useMemo<Column<Wanted.Episode>[]>(
() => [
{
@@ -48,11 +40,13 @@ const WantedSeriesView: FunctionComponent<Props> = () => {
{
Header: "Missing",
accessor: "missing_subtitles",
- Cell: ({ row, update, value }) => {
+ Cell: ({ row, value }) => {
const wanted = row.original;
const hi = wanted.hearing_impaired;
- const seriesid = wanted.sonarrSeriesId;
- const episodeid = wanted.sonarrEpisodeId;
+ const seriesId = wanted.sonarrSeriesId;
+ const episodeId = wanted.sonarrEpisodeId;
+
+ const { download } = useEpisodeSubtitleModification();
return value.map((item, idx) => (
<AsyncButton
@@ -61,13 +55,16 @@ const WantedSeriesView: FunctionComponent<Props> = () => {
className="mx-1 mr-2"
variant="secondary"
promise={() =>
- EpisodesApi.downloadSubtitles(seriesid, episodeid, {
- language: item.code2,
- hi,
- forced: false,
+ download.mutateAsync({
+ seriesId,
+ episodeId,
+ form: {
+ language: item.code2,
+ hi,
+ forced: false,
+ },
})
}
- onSuccess={() => update && update(row, episodeid)}
>
<LanguageText className="pr-1" text={item}></LanguageText>
<FontAwesomeIcon size="sm" icon={faSearch}></FontAwesomeIcon>
@@ -79,14 +76,15 @@ const WantedSeriesView: FunctionComponent<Props> = () => {
[]
);
+ const { mutateAsync } = useSeriesAction();
+ const query = useEpisodeWantedPagination();
return (
- <GenericWantedView
- type="series"
+ <WantedView
+ name="Series"
columns={columns}
- state={series}
- loader={loader}
- searchAll={searchAll}
- ></GenericWantedView>
+ query={query}
+ searchAll={() => mutateAsync({ action: "search-wanted" })}
+ ></WantedView>
);
};
diff --git a/frontend/src/utilities/async.ts b/frontend/src/utilities/async.ts
deleted file mode 100644
index de363de32..000000000
--- a/frontend/src/utilities/async.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { difference, intersection, isString } from "lodash";
-import { useEffect, useMemo, useState } from "react";
-import { useEffectOnceWhen } from "rooks";
-import { useEntityIdByRange, useEntityToItem } from "./entity";
-
-export async function waitFor(time: number) {
- return new Promise((resolved) => {
- setTimeout(resolved, time);
- });
-}
-
-export function useNewEntityIds(entity: Async.Entity<any>) {
- return useMemo(() => {
- const dirtyEntities = entity.dirtyEntities;
- const rawIds = entity.content.ids;
-
- const ids = rawIds.filter(isString);
-
- return difference(dirtyEntities, ids);
- }, [entity.dirtyEntities, entity.content.ids]);
-}
-
-export function useDirtyEntityIds(
- entity: Async.Entity<any>,
- start: number,
- end: number
-) {
- const ids = useEntityIdByRange(entity.content, start, end);
-
- return useMemo(() => {
- const dirtyIds = entity.dirtyEntities;
- return intersection(ids, dirtyIds);
- }, [ids, entity.dirtyEntities]);
-}
-
-export function useEntityItemById<T>(
- entity: Async.Entity<T>,
- id: string
-): Async.Item<T> {
- const { content, dirtyEntities, didLoaded, error, state } = entity;
- const item = useEntityToItem(content, id);
-
- const newState = useMemo<Async.State>(() => {
- switch (state) {
- case "loading":
- return state;
- default:
- if (dirtyEntities.find((v) => v === id)) {
- return "dirty";
- } else if (!didLoaded.find((v) => v === id)) {
- return "uninitialized";
- } else {
- return state;
- }
- }
- }, [dirtyEntities, id, state, didLoaded]);
-
- return useMemo(
- () => ({ content: item, state: newState, error }),
- [error, newState, item]
- );
-}
-
-export function useOnLoadedOnce(callback: () => void, entity: Async.Base<any>) {
- const [didLoaded, setLoaded] = useState(false);
-
- const { state } = entity;
-
- const isLoaded = state !== "loading";
-
- useEffect(() => {
- if (!isLoaded) {
- setLoaded(true);
- }
- }, [isLoaded]);
-
- useEffectOnceWhen(callback, didLoaded && isLoaded);
-}
diff --git a/frontend/src/constants.ts b/frontend/src/utilities/constants.ts
index 6320fa4f3..6320fa4f3 100644
--- a/frontend/src/constants.ts
+++ b/frontend/src/utilities/constants.ts
diff --git a/frontend/src/utilities/entity.ts b/frontend/src/utilities/entity.ts
deleted file mode 100644
index 8326b20ff..000000000
--- a/frontend/src/utilities/entity.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { isNull, isString } from "lodash";
-import { useMemo } from "react";
-
-export function useIsEntityLoaded(
- entity: EntityStruct<any>,
- start: number,
- end: number
-): boolean {
- return useMemo(
- () => entity.ids.slice(start, end).filter(isNull).length === 0,
- [entity.ids, start, end]
- );
-}
-
-export function useEntityIdByRange(
- entity: EntityStruct<any>,
- start: number,
- end: number
-): string[] {
- return useMemo(() => {
- const ids = entity.ids;
- return ids.slice(start, end).flatMap((v) => {
- if (isString(v)) {
- return [v];
- } else {
- return [];
- }
- });
- }, [entity.ids, start, end]);
-}
-
-export function useEntityByRange<T>(
- entity: EntityStruct<T>,
- start: number,
- end: number
-): T[] {
- const filteredIds = useEntityIdByRange(entity, start, end);
- const content = useMemo<T[]>(() => {
- const entities = entity.entities;
- return filteredIds.map((v) => entities[v]);
- }, [entity.entities, filteredIds]);
- return content;
-}
-
-export function useEntityToList<T>(entity: EntityStruct<T>): T[] {
- return useMemo(
- () => entity.ids.filter(isString).map((v) => entity.entities[v]),
- [entity]
- );
-}
-
-export function useEntityToItem<T>(
- entity: EntityStruct<T>,
- id: string
-): T | null {
- return useMemo(() => {
- if (id in entity.entities) {
- return entity.entities[id];
- } else {
- return null;
- }
- }, [entity.entities, id]);
-}
diff --git a/frontend/src/utilities/env.ts b/frontend/src/utilities/env.ts
index 12a4f03e6..f21576a0b 100644
--- a/frontend/src/utilities/env.ts
+++ b/frontend/src/utilities/env.ts
@@ -42,4 +42,10 @@ export const Environment = {
return url;
}
},
+ get queryDev(): boolean {
+ if (isDevEnv) {
+ return process.env["REACT_APP_QUERY_DEV"] === "true";
+ }
+ return false;
+ },
};
diff --git a/frontend/src/utilities/index.ts b/frontend/src/utilities/index.ts
index 9b65c53f2..b84c5cf3d 100644
--- a/frontend/src/utilities/index.ts
+++ b/frontend/src/utilities/index.ts
@@ -25,7 +25,7 @@ export function submodProcessColor(s: string) {
return `color(name=${s})`;
}
-export function GetItemId<T extends object>(item: T): number {
+export function GetItemId<T extends object>(item: T): number | undefined {
if (isMovie(item)) {
return item.radarrId;
} else if (isEpisode(item)) {
@@ -33,7 +33,7 @@ export function GetItemId<T extends object>(item: T): number {
} else if (isSeries(item)) {
return item.sonarrSeriesId;
} else {
- return -1;
+ return undefined;
}
}
@@ -67,8 +67,12 @@ export function filterSubtitleBy(
}
}
-export * from "./async";
-export * from "./entity";
+export async function waitFor(time: number) {
+ return new Promise((resolved) => {
+ setTimeout(resolved, time);
+ });
+}
+
export * from "./env";
export * from "./hooks";
export * from "./validate";
diff --git a/frontend/src/utilities/languages.ts b/frontend/src/utilities/languages.ts
new file mode 100644
index 000000000..7077da959
--- /dev/null
+++ b/frontend/src/utilities/languages.ts
@@ -0,0 +1,49 @@
+import { useLanguageProfiles, useLanguages } from "apis/hooks";
+import { useMemo } from "react";
+
+export function useLanguageProfileBy(id: number | null | undefined) {
+ const { data } = useLanguageProfiles();
+ return useMemo(() => data?.find((v) => v.profileId === id), [id, data]);
+}
+
+export function useEnabledLanguages() {
+ const query = useLanguages();
+
+ const enabled = useMemo(() => {
+ const data =
+ query.data
+ ?.filter((v) => v.enabled)
+ .map((v) => ({ code2: v.code2, name: v.name })) ?? [];
+
+ return {
+ ...query,
+ data,
+ };
+ }, [query]);
+
+ return enabled;
+}
+
+export function useLanguageBy(code?: string) {
+ const { data } = useLanguages();
+ return useMemo(() => data?.find((v) => v.code2 === code), [data, code]);
+}
+
+// Convert languageprofile items to language
+export function useProfileItemsToLanguages(profile?: Language.Profile) {
+ const { data } = useLanguages();
+
+ return useMemo(
+ () =>
+ profile?.items.map<Language.Info>(({ language: code, hi, forced }) => {
+ const name = data?.find((v) => v.code2 === code)?.name ?? "";
+ return {
+ hi: hi === "True",
+ forced: forced === "True",
+ code2: code,
+ name,
+ };
+ }) ?? [],
+ [data, profile?.items]
+ );
+}
diff --git a/frontend/src/@storage/local.ts b/frontend/src/utilities/storage.ts
index 1d7e39d97..1d7e39d97 100644
--- a/frontend/src/@storage/local.ts
+++ b/frontend/src/utilities/storage.ts
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index e47ff4ac4..42c4e3462 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,19 +1,22 @@
{
"compilerOptions": {
- "target": "es6",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "strict": true,
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
- "strict": true,
"forceConsistentCasingInFileNames": true,
- "module": "esnext",
- "moduleResolution": "node",
+ "module": "ESNext",
+ "moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
+ "baseUrl": "src",
+ "incremental": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]