From d8d2300980ca69a4ae6511cb49a6dc548c0da793 Mon Sep 17 00:00:00 2001 From: Liang Yi Date: Sat, 22 Jan 2022 21:35:11 +0800 Subject: [PATCH] Add React-Query to improve network and cache performance --- frontend/.env.development | 3 + frontend/package-lock.json | 203 +++++++-- frontend/package.json | 2 +- frontend/src/@modules/socketio/reducer.ts | 144 ++++--- frontend/src/@modules/task/hooks.ts | 17 - .../@redux/__tests__/entity-reducer.test.ts | 406 ------------------ .../src/@redux/__tests__/item-reducer.test.ts | 155 ------- .../src/@redux/__tests__/list-reducer.test.ts | 252 ----------- frontend/src/@redux/actions/index.ts | 42 +- frontend/src/@redux/actions/movie.ts | 84 ---- frontend/src/@redux/actions/series.ts | 106 ----- frontend/src/@redux/actions/site.ts | 62 --- frontend/src/@redux/actions/system.ts | 87 ---- frontend/src/@redux/hooks/async.ts | 29 -- frontend/src/@redux/hooks/index.ts | 43 +- frontend/src/@redux/hooks/movies.ts | 67 --- frontend/src/@redux/hooks/series.ts | 99 ----- frontend/src/@redux/hooks/site.ts | 39 -- frontend/src/@redux/hooks/system.ts | 131 ------ frontend/src/@redux/reducers/index.ts | 119 ++++- frontend/src/@redux/reducers/movie.ts | 68 --- frontend/src/@redux/reducers/series.ts | 100 ----- frontend/src/@redux/reducers/site.ts | 130 ------ frontend/src/@redux/reducers/system.ts | 74 ---- frontend/src/@redux/store/index.ts | 2 +- frontend/src/@redux/tests/helper.ts | 31 -- .../src/@redux/utils/__tests__/async-test.ts | 32 -- frontend/src/@redux/utils/factory.ts | 339 --------------- frontend/src/@redux/utils/index.ts | 88 ---- frontend/src/@types/api.d.ts | 2 +- frontend/src/@types/async.d.ts | 24 -- frontend/src/@types/function.d.ts | 3 + frontend/src/@types/site.d.ts | 1 + frontend/src/@types/utilities.d.ts | 2 +- frontend/src/App/Header.tsx | 68 +-- frontend/src/App/Notification.tsx | 6 +- frontend/src/App/index.tsx | 48 +-- frontend/src/Blacklist/Movies/index.tsx | 39 -- frontend/src/Blacklist/Series/index.tsx | 39 -- .../generic/BaseItemView/index.tsx | 177 -------- .../generic/BaseItemView/table.tsx | 78 ---- frontend/src/History/Statistics/index.tsx | 149 ------- frontend/src/History/generic/index.tsx | 40 -- frontend/src/Navigation/RootRedirect.tsx | 2 +- frontend/src/Navigation/index.ts | 66 +-- frontend/src/Router/index.tsx | 4 +- frontend/src/Sidebar/index.tsx | 18 +- frontend/src/System/Logs/index.tsx | 66 --- frontend/src/System/Providers/index.tsx | 50 --- frontend/src/System/Tasks/index.tsx | 42 -- frontend/src/Wanted/generic/index.tsx | 65 --- frontend/src/__tests__/render-test.tsx | 4 +- frontend/src/apis/hooks.ts | 31 -- frontend/src/apis/hooks/episodes.ts | 115 +++++ frontend/src/apis/hooks/histories.ts | 21 + frontend/src/apis/hooks/index.ts | 9 + frontend/src/apis/hooks/languages.ts | 23 + frontend/src/apis/hooks/movies.ts | 138 ++++++ frontend/src/apis/hooks/providers.ts | 99 +++++ frontend/src/apis/hooks/series.ts | 80 ++++ frontend/src/apis/hooks/status.ts | 18 + frontend/src/apis/hooks/subtitles.ts | 119 +++++ frontend/src/apis/hooks/system.ts | 188 ++++++++ .../src/apis/{index.ts => queries/client.ts} | 23 +- frontend/src/apis/queries/hooks.ts | 116 +++++ frontend/src/apis/queries/index.ts | 14 + frontend/src/apis/queries/keys.ts | 23 + frontend/src/apis/{ => raw}/badges.ts | 0 frontend/src/apis/{ => raw}/base.ts | 10 +- frontend/src/apis/{ => raw}/episodes.ts | 10 +- frontend/src/apis/{ => raw}/files.ts | 0 frontend/src/apis/{ => raw}/history.ts | 4 +- frontend/src/apis/raw/index.ts | 25 ++ frontend/src/apis/{ => raw}/movies.ts | 26 +- frontend/src/apis/{ => raw}/providers.ts | 0 frontend/src/apis/{ => raw}/series.ts | 9 +- frontend/src/apis/{ => raw}/subtitles.ts | 0 frontend/src/apis/{ => raw}/system.ts | 2 +- frontend/src/apis/{ => raw}/utils.ts | 6 +- .../ErrorBoundary.tsx | 2 +- .../generic => components}/ItemOverview.tsx | 11 +- frontend/src/components/LanguageSelector.tsx | 2 +- frontend/src/components/SearchBar.tsx | 69 +-- frontend/src/components/async.tsx | 73 +--- .../src/components/inputs/FileBrowser.tsx | 57 ++- .../inputs}/blacklist.tsx | 2 +- .../src/components/modals/HistoryModal.tsx | 107 +++-- .../src/components/modals/ItemEditorModal.tsx | 14 +- .../components/modals/ManualSearchModal.tsx | 87 ++-- .../components/modals/MovieUploadModal.tsx | 30 +- .../components/modals/SeriesUploadModal.tsx | 39 +- .../components/modals/SubtitleToolModal.tsx | 26 +- .../components/modals/SubtitleUploadModal.tsx | 2 +- frontend/src/components/modals/hooks.tsx | 2 +- .../src/components/tables/AsyncPageTable.tsx | 128 ------ frontend/src/components/tables/PageTable.tsx | 2 +- .../src/components/tables/QueryPageTable.tsx | 77 ++++ frontend/src/components/tables/index.tsx | 2 +- .../tables/plugins/useDefaultSettings.tsx | 2 +- frontend/src/components/views/HistoryView.tsx | 36 ++ frontend/src/components/views/ItemView.tsx | 213 +++++++++ frontend/src/components/views/WantedView.tsx | 60 +++ frontend/src/index.tsx | 21 +- frontend/src/{special-pages => pages}/404.tsx | 0 .../Authentication.scss} | 0 .../AuthPage.tsx => pages/Authentication.tsx} | 62 +-- frontend/src/pages/Blacklist/Movies/index.tsx | 40 ++ .../{ => pages}/Blacklist/Movies/table.tsx | 22 +- frontend/src/pages/Blacklist/Series/index.tsx | 39 ++ .../{ => pages}/Blacklist/Series/table.tsx | 23 +- .../Episodes/components.tsx | 35 +- .../{DisplayItem => pages}/Episodes/index.tsx | 134 +++--- .../{DisplayItem => pages}/Episodes/table.tsx | 120 +++--- .../src/{ => pages}/History/Movies/index.tsx | 32 +- .../src/{ => pages}/History/Series/index.tsx | 33 +- .../src/pages/History/Statistics/index.tsx | 126 ++++++ .../{ => pages}/History/Statistics/options.ts | 2 +- .../{special-pages => pages}/LaunchError.tsx | 2 +- .../Movies/Details}/index.tsx | 138 +++--- .../Movies/Details}/table.tsx | 46 +- .../{DisplayItem => pages}/Movies/index.tsx | 35 +- .../{DisplayItem => pages}/Series/index.tsx | 36 +- .../{ => pages}/Settings/General/index.tsx | 2 +- .../{ => pages}/Settings/General/options.ts | 0 .../Settings/Languages/components.tsx | 2 +- .../{ => pages}/Settings/Languages/index.tsx | 15 +- .../{ => pages}/Settings/Languages/modal.tsx | 22 +- .../{ => pages}/Settings/Languages/options.ts | 0 .../{ => pages}/Settings/Languages/table.tsx | 2 +- .../Settings/Notifications/components.tsx | 22 +- .../Settings/Notifications/index.tsx | 0 .../Settings/Providers/components.tsx | 16 +- .../{ => pages}/Settings/Providers/index.tsx | 0 .../{ => pages}/Settings/Providers/list.ts | 0 .../src/{ => pages}/Settings/Radarr/index.tsx | 2 +- .../{ => pages}/Settings/Scheduler/index.tsx | 0 .../{ => pages}/Settings/Scheduler/options.ts | 0 .../src/{ => pages}/Settings/Sonarr/index.tsx | 2 +- .../{ => pages}/Settings/Subtitles/index.tsx | 3 +- .../{ => pages}/Settings/Subtitles/options.ts | 0 .../src/{ => pages}/Settings/UI/index.tsx | 2 +- .../src/{ => pages}/Settings/UI/options.ts | 0 .../Settings/components/collapse.tsx | 0 .../Settings/components/container.tsx | 0 .../{ => pages}/Settings/components/forms.tsx | 18 +- .../{ => pages}/Settings/components/hooks.ts | 6 +- .../{ => pages}/Settings/components/index.tsx | 11 +- .../Settings/components/pathMapper.tsx | 17 +- .../Settings/components/provider.tsx | 43 +- .../Settings/components/style.scss | 0 frontend/src/{ => pages}/Settings/keys.ts | 0 frontend/src/{ => pages}/Settings/options.ts | 0 frontend/src/pages/System/Logs/index.tsx | 55 +++ .../src/{ => pages}/System/Logs/modal.tsx | 2 +- .../src/{ => pages}/System/Logs/table.tsx | 2 +- frontend/src/pages/System/Providers/index.tsx | 48 +++ .../{ => pages}/System/Providers/table.tsx | 2 +- .../src/{ => pages}/System/Releases/index.tsx | 37 +- .../src/{ => pages}/System/Status/index.tsx | 16 +- .../src/{ => pages}/System/Status/style.scss | 0 .../src/{ => pages}/System/Status/table.tsx | 2 +- frontend/src/pages/System/Tasks/index.tsx | 39 ++ .../src/{ => pages}/System/Tasks/table.tsx | 7 +- .../src/{special-pages => pages}/UIError.tsx | 4 +- .../src/{ => pages}/Wanted/Movies/index.tsx | 59 ++- .../src/{ => pages}/Wanted/Series/index.tsx | 60 ++- frontend/src/utilities/async.ts | 78 ---- frontend/src/{ => utilities}/constants.ts | 0 frontend/src/utilities/entity.ts | 63 --- frontend/src/utilities/env.ts | 6 + frontend/src/utilities/index.ts | 12 +- frontend/src/utilities/languages.ts | 49 +++ .../local.ts => utilities/storage.ts} | 0 frontend/tsconfig.json | 13 +- 174 files changed, 3177 insertions(+), 4607 deletions(-) delete mode 100644 frontend/src/@modules/task/hooks.ts delete mode 100644 frontend/src/@redux/__tests__/entity-reducer.test.ts delete mode 100644 frontend/src/@redux/__tests__/item-reducer.test.ts delete mode 100644 frontend/src/@redux/__tests__/list-reducer.test.ts delete mode 100644 frontend/src/@redux/actions/movie.ts delete mode 100644 frontend/src/@redux/actions/series.ts delete mode 100644 frontend/src/@redux/actions/site.ts delete mode 100644 frontend/src/@redux/actions/system.ts delete mode 100644 frontend/src/@redux/hooks/async.ts delete mode 100644 frontend/src/@redux/hooks/movies.ts delete mode 100644 frontend/src/@redux/hooks/series.ts delete mode 100644 frontend/src/@redux/hooks/site.ts delete mode 100644 frontend/src/@redux/hooks/system.ts delete mode 100644 frontend/src/@redux/reducers/movie.ts delete mode 100644 frontend/src/@redux/reducers/series.ts delete mode 100644 frontend/src/@redux/reducers/site.ts delete mode 100644 frontend/src/@redux/reducers/system.ts delete mode 100644 frontend/src/@redux/tests/helper.ts delete mode 100644 frontend/src/@redux/utils/__tests__/async-test.ts delete mode 100644 frontend/src/@redux/utils/factory.ts delete mode 100644 frontend/src/@redux/utils/index.ts delete mode 100644 frontend/src/@types/async.d.ts create mode 100644 frontend/src/@types/function.d.ts delete mode 100644 frontend/src/Blacklist/Movies/index.tsx delete mode 100644 frontend/src/Blacklist/Series/index.tsx delete mode 100644 frontend/src/DisplayItem/generic/BaseItemView/index.tsx delete mode 100644 frontend/src/DisplayItem/generic/BaseItemView/table.tsx delete mode 100644 frontend/src/History/Statistics/index.tsx delete mode 100644 frontend/src/History/generic/index.tsx delete mode 100644 frontend/src/System/Logs/index.tsx delete mode 100644 frontend/src/System/Providers/index.tsx delete mode 100644 frontend/src/System/Tasks/index.tsx delete mode 100644 frontend/src/Wanted/generic/index.tsx delete mode 100644 frontend/src/apis/hooks.ts create mode 100644 frontend/src/apis/hooks/episodes.ts create mode 100644 frontend/src/apis/hooks/histories.ts create mode 100644 frontend/src/apis/hooks/index.ts create mode 100644 frontend/src/apis/hooks/languages.ts create mode 100644 frontend/src/apis/hooks/movies.ts create mode 100644 frontend/src/apis/hooks/providers.ts create mode 100644 frontend/src/apis/hooks/series.ts create mode 100644 frontend/src/apis/hooks/status.ts create mode 100644 frontend/src/apis/hooks/subtitles.ts create mode 100644 frontend/src/apis/hooks/system.ts rename frontend/src/apis/{index.ts => queries/client.ts} (67%) create mode 100644 frontend/src/apis/queries/hooks.ts create mode 100644 frontend/src/apis/queries/index.ts create mode 100644 frontend/src/apis/queries/keys.ts rename frontend/src/apis/{ => raw}/badges.ts (100%) rename frontend/src/apis/{ => raw}/base.ts (79%) rename frontend/src/apis/{ => raw}/episodes.ts (85%) rename frontend/src/apis/{ => raw}/files.ts (100%) rename frontend/src/apis/{ => raw}/history.ts (87%) create mode 100644 frontend/src/apis/raw/index.ts rename frontend/src/apis/{ => raw}/movies.ts (74%) rename frontend/src/apis/{ => raw}/providers.ts (100%) rename frontend/src/apis/{ => raw}/series.ts (70%) rename frontend/src/apis/{ => raw}/subtitles.ts (100%) rename frontend/src/apis/{ => raw}/system.ts (98%) rename frontend/src/apis/{ => raw}/utils.ts (80%) rename frontend/src/{special-pages => components}/ErrorBoundary.tsx (93%) rename frontend/src/{DisplayItem/generic => components}/ItemOverview.tsx (95%) rename frontend/src/{DisplayItem/generic => components/inputs}/blacklist.tsx (95%) delete mode 100644 frontend/src/components/tables/AsyncPageTable.tsx create mode 100644 frontend/src/components/tables/QueryPageTable.tsx create mode 100644 frontend/src/components/views/HistoryView.tsx create mode 100644 frontend/src/components/views/ItemView.tsx create mode 100644 frontend/src/components/views/WantedView.tsx rename frontend/src/{special-pages => pages}/404.tsx (100%) rename frontend/src/{special-pages/AuthPage.scss => pages/Authentication.scss} (100%) rename frontend/src/{special-pages/AuthPage.tsx => pages/Authentication.tsx} (55%) create mode 100644 frontend/src/pages/Blacklist/Movies/index.tsx rename frontend/src/{ => pages}/Blacklist/Movies/table.tsx (82%) create mode 100644 frontend/src/pages/Blacklist/Series/index.tsx rename frontend/src/{ => pages}/Blacklist/Series/table.tsx (83%) rename frontend/src/{DisplayItem => pages}/Episodes/components.tsx (65%) rename frontend/src/{DisplayItem => pages}/Episodes/index.tsx (51%) rename frontend/src/{DisplayItem => pages}/Episodes/table.tsx (72%) rename frontend/src/{ => pages}/History/Movies/index.tsx (78%) rename frontend/src/{ => pages}/History/Series/index.tsx (82%) create mode 100644 frontend/src/pages/History/Statistics/index.tsx rename frontend/src/{ => pages}/History/Statistics/options.ts (88%) rename frontend/src/{special-pages => pages}/LaunchError.tsx (95%) rename frontend/src/{DisplayItem/MovieDetail => pages/Movies/Details}/index.tsx (55%) rename frontend/src/{DisplayItem/MovieDetail => pages/Movies/Details}/table.tsx (74%) rename frontend/src/{DisplayItem => pages}/Movies/index.tsx (79%) rename frontend/src/{DisplayItem => pages}/Series/index.tsx (79%) rename frontend/src/{ => pages}/Settings/General/index.tsx (98%) rename frontend/src/{ => pages}/Settings/General/options.ts (100%) rename frontend/src/{ => pages}/Settings/Languages/components.tsx (94%) rename frontend/src/{ => pages}/Settings/Languages/index.tsx (92%) rename frontend/src/{ => pages}/Settings/Languages/modal.tsx (99%) rename frontend/src/{ => pages}/Settings/Languages/options.ts (100%) rename frontend/src/{ => pages}/Settings/Languages/table.tsx (98%) rename frontend/src/{ => pages}/Settings/Notifications/components.tsx (96%) rename frontend/src/{ => pages}/Settings/Notifications/index.tsx (100%) rename frontend/src/{ => pages}/Settings/Providers/components.tsx (98%) rename frontend/src/{ => pages}/Settings/Providers/index.tsx (100%) rename frontend/src/{ => pages}/Settings/Providers/list.ts (100%) rename frontend/src/{ => pages}/Settings/Radarr/index.tsx (98%) rename frontend/src/{ => pages}/Settings/Scheduler/index.tsx (100%) rename frontend/src/{ => pages}/Settings/Scheduler/options.ts (100%) rename frontend/src/{ => pages}/Settings/Sonarr/index.tsx (98%) rename frontend/src/{ => pages}/Settings/Subtitles/index.tsx (99%) rename frontend/src/{ => pages}/Settings/Subtitles/options.ts (100%) rename frontend/src/{ => pages}/Settings/UI/index.tsx (90%) rename frontend/src/{ => pages}/Settings/UI/options.ts (100%) rename frontend/src/{ => pages}/Settings/components/collapse.tsx (100%) rename frontend/src/{ => pages}/Settings/components/container.tsx (100%) rename frontend/src/{ => pages}/Settings/components/forms.tsx (98%) rename frontend/src/{ => pages}/Settings/components/hooks.ts (95%) rename frontend/src/{ => pages}/Settings/components/index.tsx (91%) rename frontend/src/{ => pages}/Settings/components/pathMapper.tsx (90%) rename frontend/src/{ => pages}/Settings/components/provider.tsx (82%) rename frontend/src/{ => pages}/Settings/components/style.scss (100%) rename frontend/src/{ => pages}/Settings/keys.ts (100%) rename frontend/src/{ => pages}/Settings/options.ts (100%) create mode 100644 frontend/src/pages/System/Logs/index.tsx rename frontend/src/{ => pages}/System/Logs/modal.tsx (88%) rename frontend/src/{ => pages}/System/Logs/table.tsx (96%) create mode 100644 frontend/src/pages/System/Providers/index.tsx rename frontend/src/{ => pages}/System/Providers/table.tsx (93%) rename frontend/src/{ => pages}/System/Releases/index.tsx (69%) rename frontend/src/{ => pages}/System/Status/index.tsx (91%) rename frontend/src/{ => pages}/System/Status/style.scss (100%) rename frontend/src/{ => pages}/System/Status/table.tsx (93%) create mode 100644 frontend/src/pages/System/Tasks/index.tsx rename frontend/src/{ => pages}/System/Tasks/table.tsx (88%) rename frontend/src/{special-pages => pages}/UIError.tsx (91%) rename frontend/src/{ => pages}/Wanted/Movies/index.tsx (55%) rename frontend/src/{ => pages}/Wanted/Series/index.tsx (58%) delete mode 100644 frontend/src/utilities/async.ts rename frontend/src/{ => utilities}/constants.ts (100%) delete mode 100644 frontend/src/utilities/entity.ts create mode 100644 frontend/src/utilities/languages.ts rename frontend/src/{@storage/local.ts => utilities/storage.ts} (100%) 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>(action: T) { return (...args: Parameters) => { @@ -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>((resolve) => { - resolve({ total: defaultList.length, data: defaultList }); - }); -}); - -const allResolvedLonger = createAsyncThunk("all/longer/resolved", () => { - return new Promise>((resolve) => { - resolve({ total: longerList.length, data: longerList }); - }); -}); - -const allResolvedShorter = createAsyncThunk("all/shorter/resolved", () => { - return new Promise>((resolve) => { - resolve({ total: shorterList.length, data: shorterList }); - }); -}); - -const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => { - return new Promise>((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>((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>((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>((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>((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>((resolve) => { - resolve({ - total: shorterList.length, - data: shorterList.slice(param.start, param.start + param.length), - }); - }); - } -); - -const allRejected = createAsyncThunk("all/rejected", () => { - return new Promise>((resolve, rejected) => { - rejected("Error"); - }); -}); -const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => { - return new Promise>((resolve, rejected) => { - rejected("Error"); - }); -}); -const rangeRejected = createAsyncThunk( - "range/rejected", - (param: Parameter.Range) => { - return new Promise>((resolve, rejected) => { - rejected("Error"); - }); - } -); -const removeIds = createAction("remove/id"); -const dirty = createAction("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) => 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((resolve) => { - resolve(defaultItem); - }); -}); -const allRejected = createAsyncThunk("all/rejected", () => { - return new Promise((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) => void) { - const item = store.getState().item; - callback(item); -} - -// Begin Test Section - -beforeEach(() => { - store = createStore(); -}); - -it("item loading", async () => { - return new Promise((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((resolve) => { - resolve(defaultList); - }); -}); -const allRejected = createAsyncThunk("all/rejected", () => { - return new Promise((resolve, rejected) => { - rejected("Error"); - }); -}); -const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => { - return new Promise((resolve) => { - resolve(intersectionWith(defaultList, param, (l, r) => l.id === r)); - }); -}); -const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => { - return new Promise((resolve, rejected) => { - rejected("Error"); - }); -}); -const removeIds = createAction("remove/id"); -const dirty = createAction("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) => 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/update"); + +export const setUnauthenticated = createAction("site/unauthenticated"); + +export const setOfflineStatus = createAction("site/offline/update"); + +export const addNotifications = createAction( + "site/notifications/add" +); + +export const removeNotification = createAction( + "site/notifications/remove" +); + +export const siteAddProgress = + createAction("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( + "site/progress/update_notifier" +); + +export const setSidebar = createAction("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("movies/remove"); - -export const movieMarkDirtyById = createAction( - "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( - "movies/wanted/remove/id" -); - -export const movieResetWanted = createAction("movies/wanted/reset"); - -export const movieMarkWantedDirtyById = createAction( - "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( - "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( - "series/wanted/remove/id" -); - -export const seriesResetWanted = createAction("series/wanted/reset"); - -export const seriesMarkWantedDirtyById = createAction( - "series/wanted/mark_dirty/episode_id" -); - -export const seriesRemoveById = createAction("series/remove"); - -export const seriesMarkDirtyById = createAction( - "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("episodes/remove"); - -export const episodesMarkDirtyById = createAction( - "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( - "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( - "site/initialization/update" -); - -export const siteRedirectToAuth = createAction("site/redirect_auth"); - -export const siteAddNotifications = createAction( - "site/notifications/add" -); - -export const siteRemoveNotifications = createAction( - "site/notifications/remove" -); - -export const siteAddProgress = - createAction("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( - "site/progress/update_notifier" -); - -export const siteChangeSidebarVisibility = createAction( - "site/sidebar/visibility" -); - -export const siteUpdateOffline = createAction("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, update: () => void) { - useEffect(() => { - if (item.state === "uninitialized" || item.state === "dirty") { - update(); - } - }, [item.state, update]); -} - -export function useAutoDirtyUpdate( - item: Async.List | Async.Entity, - updateAction: AsyncThunk -) { - 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) => { + 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>(() => { - 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>(() => { - 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 = 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) => { - 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( - () => 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( - () => - 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: 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; - wantedMovieList: Async.Entity; - historyList: Async.Entity; - blacklist: Async.Item; -} - -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; - wantedEpisodesList: Async.Entity; - episodeList: Async.List; - historyList: Async.Entity; - blacklist: Async.Item; -} - -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; - languagesProfiles: Async.Item; - status: Async.Item; - health: Async.Item; - tasks: Async.Item; - providers: Async.Item; - logs: Async.Item; - releases: Async.Item; - settings: Async.Item; -} - -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; - list: Async.List; - entities: Async.Entity; -} - -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(); - expect(item.state).toEqual("uninitialized"); - expect(item.error).toBeNull(); - expect(item.content).toBeNull(); -}); - -it("List Init", () => { - const list = AsyncUtility.getDefaultList("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("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 { - range?: AsyncThunk; - all?: AsyncThunk; - ids?: AsyncThunk; - removeIds?: ActionCreatorWithPayload; - reset?: ActionCreatorWithoutPayload; - dirty?: ID extends null - ? ActionCreatorWithoutPayload - : ActionCreatorWithPayload; -} - -export function createAsyncItemReducer( - builder: ActionReducerMapBuilder, - getItem: (state: Draft) => Draft>, - actions: Pick, "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; - }) - .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( - builder: ActionReducerMapBuilder, - getList: (state: Draft) => Draft>, - actions: ActionParam -) { - 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); - } else { - list.content.unshift(v as Draft); - } - }); - - 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; - 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( - builder: ActionReducerMapBuilder, - getEntity: (state: Draft) => Draft>, - actions: ActionParam, ID> -) { - const { all, removeIds, ids, range, dirty, reset } = actions; - - const checkSizeUpdate = (entity: Draft>, 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; - }); - - 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; - }); - - 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; - 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(): Async.Item { - return { - state: "uninitialized", - content: null, - error: null, - }; - } - - export function getDefaultList(key: keyof T): Async.List { - return { - state: "uninitialized", - keyName: key, - dirtyEntities: [], - didLoaded: [], - content: [], - error: null, - }; - } - - export function getDefaultEntity(key: keyof T): Async.Entity { - return { - state: "uninitialized", - dirtyEntities: [], - didLoaded: [], - content: { - keyName: key, - ids: [], - entities: {}, - }, - error: null, - }; - } -} - -export namespace ReducerUtility { - type DirtyType = Draft> | Draft>; - export function markDirty( - 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( - 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( - entity: T, - removedIds: string[] - ) { - pullAll(entity.dirtyEntities, removedIds); - if (entity.dirtyEntities.length === 0 && entity.state === "dirty") { - entity.state = "succeeded"; - } - } - - export function updateDidLoaded( - entity: T, - loadedIds: string[] - ) { - entity.didLoaded.push(...loadedIds); - entity.didLoaded = uniq(entity.didLoaded); - } - - export function removeDidLoaded( - 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 = { - state: State; - content: T; - error: string | null; - }; - - type List = Base & { - keyName: keyof T; - dirtyEntities: string[]; - didLoaded: string[]; - }; - - type Item = Base; - - type Entity = Base> & { - 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 = ( + param: Parameter.Range +) => Promise>; 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 { data: T; } -interface AsyncDataWrapper { +interface DataWrapperWithTotal { 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((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 = () => { - 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( () => ( @@ -80,23 +50,23 @@ const Header: FunctionComponent = () => { { - SystemApi.restart(); + restart(); }} > Restart { - SystemApi.shutdown(); + shutdown(); }} > Shutdown - + ), - [canLogout, setNeedAuth] + [hasLogout, logout, restart, shutdown] ); const goHome = useGotoHomepage(); @@ -133,7 +103,7 @@ const Header: FunctionComponent = () => { - + 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(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 = () => { - 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 = () => { // TODO: Restart action }); } - }, initialized === true); + }, status === "initialized"); - if (!auth) { + if (status === "unauthenticated") { return ; - } - - if (typeof initialized === "boolean" && initialized === false) { + } else if (status === "uninitialized") { return ( Please wait ); - } else if (typeof initialized === "string") { - return {initialized}; + } else if (status === "error") { + return Cannot Initialize Bazarr; } + return ( @@ -74,7 +71,7 @@ const MainRouter: FunctionComponent = () => { - + @@ -84,15 +81,4 @@ const MainRouter: FunctionComponent = () => { ); }; -const Main: FunctionComponent = () => { - return ( - - {/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */} - {/* */} - - {/* */} - - ); -}; - -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 = () => { - const blacklist = useBlacklistMovies(); - return ( - - {({ content }) => ( - - - Movies Blacklist - Bazarr - - - MoviesApi.deleteBlacklist(true)} - > - Remove All - - - -
-
-
- )} -
- ); -}; - -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 = () => { - const blacklist = useBlacklistSeries(); - return ( - - {({ content }) => ( - - - Series Blacklist - Bazarr - - - EpisodesApi.deleteBlacklist(true)} - > - Remove All - - - -
-
-
- )} -
- ); -}; - -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 { - name: string; - loader: (params: Parameter.Range) => void; - columns: Column[]; - modify: (form: FormType.ModifyItem) => Promise; - state: Async.Entity; -} - -interface Props extends SharedProps { - updateAction: AsyncThunk, void, {}>; -} - -function BaseItemView({ - updateAction, - ...shared -}: Props) { - 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([]); - const [dirtyItems, setDirty] = useState([]); - - const profiles = useLanguageProfiles(); - - const profileOptions = useMemo(() => { - const items: JSX.Element[] = []; - if (profiles) { - items.push( - Clear Profile - ); - items.push(); - items.push( - ...profiles.map((v) => ( - - {v.name} - - )) - ); - } - - return items; - }, [profiles]); - - const changeProfiles = useCallback( - (key: Nullable) => { - 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 ( - - - {shared.name} - Bazarr - - - {editMode ? ( - - - - - Change Profile - - {profileOptions} - - - - - Cancel - - - Save - - - - ) : ( - - Mass Edit - - )} - - -
-
-
- ); -} - -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 extends SharedProps { - dirtyItems: readonly T[]; - editMode: boolean; - select: React.Dispatch; -} - -function Table({ - state, - dirtyItems, - modify, - editMode, - select, - columns, - loader, - name, -}: Props) { - const showModal = useShowModal(); - - const updateRow = useCallback>( - (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 & TableStyleProps> = { - emptyText: `No ${name} Found`, - update: updateRow, - }; - - return ( - - {editMode ? ( - // TODO: Use PageTable - - ) : ( - - )} - - - ); -} - -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 }) => ( - - {children} - -); - -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("month"); - const [action, setAction] = useState>(null); - const [lang, setLanguage] = useState>(null); - const [provider, setProvider] = useState>(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 - - - History Statistics - Bazarr - - - {({ content }) => ( - - - - setTimeframe(v ?? "month")} - > - - - - - - - - - - - - - - - - - - - - - - - - )} - - - ); -}; - -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 { - type: "movies" | "series"; - state: Readonly>; - loader: (param: Parameter.Range) => void; - columns: Column[]; -} - -function HistoryGenericView({ - state, - loader, - columns, - type, -}: Props) { - const typeName = capitalize(type); - return ( - - - {typeName} History - Bazarr - - - - - - ); -} - -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( () => [ @@ -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 = ({ [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 = () => { - const logs = useSystemLogs(); - const update = useReduxAction(systemUpdateLogs); - - const [resetting, setReset] = useState(false); - - const download = useCallback(() => { - window.open(`${Environment.baseUrl}/bazarr.log`); - }, []); - - return ( - - {({ content, state }) => ( - - - Logs - Bazarr (System) - - - - Refresh - - - Download - - { - setReset(true); - SystemApi.deleteLogs().finally(() => { - setReset(false); - update(); - }); - }} - > - Empty - - - -
-
-
- )} -
- ); -}; - -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 = () => { - const providers = useSystemProviders(); - const update = useReduxAction(providerUpdateList); - - return ( - - {({ content, state }) => ( - - - Providers - Bazarr (System) - - - - Refresh - - ProvidersApi.reset()} - onSuccess={update} - > - Reset - - - -
-
-
- )} -
- ); -}; - -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 = () => { - const tasks = useSystemTasks(); - const update = useReduxAction(systemMarkTasksDirty); - - return ( - - {({ content, state }) => ( - - - Tasks - Bazarr (System) - - - - Refresh - - - -
-
-
- )} -
- ); -}; - -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 { - type: "movies" | "series"; - columns: Column[]; - state: Async.Entity; - loader: (params: Parameter.Range) => void; - searchAll: () => Promise; -} - -const TaskGroupName = "Searching wanted subtitles..."; - -function GenericWantedView({ - type, - columns, - state, - loader, - searchAll, -}: Props) { - const typeName = capitalize(type); - - const dataCount = Object.keys(state.content.entities).length; - - const hasTask = useIsGroupTaskRunning(TaskGroupName); - - return ( - - - Wanted {typeName} - Bazarr - - - { - const task = createTask(type, undefined, searchAll); - dispatchTask(TaskGroupName, [task], "Searching..."); - }} - icon={faSearch} - > - Search All - - - - - - - ); -} - -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(, div); + ReactDOM.render(, 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; -type Return = PromiseType>; - -export function useAsyncRequest( - request: F -): [Async.Item>, (...args: Parameters) => void] { - const [state, setState] = useState>>({ - state: "uninitialized", - content: null, - error: null, - }); - - const requestRef = useRef(request); - - const update = useCallback( - (...args: Parameters) => { - 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 { + seriesId: number; + episodeId: number; + form: T; + } + + const download = useMutation( + [QueryKeys.Subtitles, QueryKeys.Episodes], + (param: Param) => + 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) => + 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) => + 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 { + radarrId: number; + form: T; + } + + const download = useMutation( + [QueryKeys.Subtitles, QueryKeys.Movies], + (param: Param) => + api.movies.downloadSubtitles(param.radarrId, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Movies, param.radarrId]); + }, + } + ); + + const remove = useMutation( + [QueryKeys.Subtitles, QueryKeys.Movies], + (param: Param) => + api.movies.deleteSubtitles(param.radarrId, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Movies, param.radarrId]); + }, + } + ); + + const upload = useMutation( + [QueryKeys.Subtitles, QueryKeys.Movies], + (param: Param) => + 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 similarity index 67% rename from frontend/src/apis/index.ts rename to 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 = UseQueryResult< + DataWrapperWithTotal +> & { + 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 +): UsePaginationQueryResult { + 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 similarity index 100% rename from frontend/src/apis/badges.ts rename to frontend/src/apis/raw/badges.ts diff --git a/frontend/src/apis/base.ts b/frontend/src/apis/raw/base.ts similarity index 79% rename from frontend/src/apis/base.ts rename to 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(path: string, params?: any) { - const response = await apis.axios.get(this.prefix + path, { params }); + const response = await client.axios.get(this.prefix + path, { params }); return response.data; } @@ -41,7 +41,7 @@ class BaseApi { params?: any ): Promise> { 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( @@ -50,7 +50,7 @@ class BaseApi { params?: any ): Promise> { 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( @@ -59,7 +59,7 @@ class BaseApi { params?: any ): Promise> { 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 similarity index 85% rename from frontend/src/apis/episodes.ts rename to 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>( + const response = await this.get>( "/wanted", params ); @@ -28,7 +28,7 @@ class EpisodeApi extends BaseApi { } async wantedBy(episodeid: number[]) { - const response = await this.get>( + const response = await this.get>( "/wanted", { episodeid } ); @@ -36,7 +36,7 @@ class EpisodeApi extends BaseApi { } async history(params: Parameter.Range) { - const response = await this.get>( + const response = await this.get>( "/history", params ); @@ -44,11 +44,11 @@ class EpisodeApi extends BaseApi { } async historyBy(episodeid: number) { - const response = await this.get>( + const response = await this.get>( "/history", { episodeid } ); - return response; + return response.data; } async downloadSubtitles( diff --git a/frontend/src/apis/files.ts b/frontend/src/apis/raw/files.ts similarity index 100% rename from frontend/src/apis/files.ts rename to frontend/src/apis/raw/files.ts diff --git a/frontend/src/apis/history.ts b/frontend/src/apis/raw/history.ts similarity index 87% rename from frontend/src/apis/history.ts rename to 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("/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 similarity index 74% rename from frontend/src/apis/movies.ts rename to 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>("", { + const response = await this.get>("", { radarrid, }); - return response; + return response.data; } async moviesBy(params: Parameter.Range) { - const response = await this.get>("", params); + const response = await this.get>( + "", + params + ); return response; } @@ -37,7 +40,7 @@ class MovieApi extends BaseApi { } async wanted(params: Parameter.Range) { - const response = await this.get>( + const response = await this.get>( "/wanted", params ); @@ -45,14 +48,17 @@ class MovieApi extends BaseApi { } async wantedBy(radarrid: number[]) { - const response = await this.get>("/wanted", { - radarrid, - }); + const response = await this.get>( + "/wanted", + { + radarrid, + } + ); return response; } async history(params: Parameter.Range) { - const response = await this.get>( + const response = await this.get>( "/history", params ); @@ -60,11 +66,11 @@ class MovieApi extends BaseApi { } async historyBy(radarrid: number) { - const response = await this.get>( + const response = await this.get>( "/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 similarity index 100% rename from frontend/src/apis/providers.ts rename to frontend/src/apis/raw/providers.ts diff --git a/frontend/src/apis/series.ts b/frontend/src/apis/raw/series.ts similarity index 70% rename from frontend/src/apis/series.ts rename to 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>("", { + const response = await this.get>("", { seriesid, }); - return response; + return response.data; } async seriesBy(params: Parameter.Range) { - const response = await this.get>("", params); + const response = await this.get>( + "", + params + ); return response; } diff --git a/frontend/src/apis/subtitles.ts b/frontend/src/apis/raw/subtitles.ts similarity index 100% rename from frontend/src/apis/subtitles.ts rename to frontend/src/apis/raw/subtitles.ts diff --git a/frontend/src/apis/system.ts b/frontend/src/apis/raw/system.ts similarity index 98% rename from frontend/src/apis/system.ts rename to 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 similarity index 80% rename from frontend/src/apis/utils.ts rename to 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( + const result = await client.axios.get( `../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( + const result = await client.axios.get( `../test/${protocol}/${url}api/v3/system/status`, { params } ); diff --git a/frontend/src/special-pages/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx similarity index 93% rename from frontend/src/special-pages/ErrorBoundary.tsx rename to 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 similarity index 95% rename from frontend/src/DisplayItem/generic/ItemOverview.tsx rename to 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) => { [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; onFocus?: () => void; onBlur?: () => void; } export const SearchBar: FunctionComponent = ({ - onSearch, onFocus, onBlur, className, }) => { - const [text, setText] = useState(""); - - const [results, setResults] = useState([]); + 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 = ({ return ( = ({ type="text" size="sm" placeholder="Search..." - value={text} - onChange={(e) => setText(e.currentTarget.value)} + value={display} + onChange={(e) => setDisplay(e.currentTarget.value)} > {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> { - ctx: T; - children: FunctionComponent; +interface QueryOverlayProps { + result: UseQueryResult; + children: React.ReactElement; } -export function AsyncOverlay>(props: Props) { - const { ctx, children } = props; - if ( - ctx.state === "uninitialized" || - (ctx.state === "loading" && isEmpty(ctx.content)) - ) { +export const QueryOverlay: FunctionComponent = ({ + children, + result: { isLoading, isError, error }, +}) => { + if (isLoading) { return ; - } else if (ctx.state === "failed") { - return

{ctx.error}

; - } else { - return children(ctx); + } else if (isError) { + return

{error as string}

; } -} + + return children; +}; interface PromiseProps { promise: () => Promise; @@ -58,48 +55,6 @@ export function PromiseOverlay({ promise, children }: PromiseProps) { } } -type AsyncSelectorProps> = { - state: T; - update: () => void; - label: (item: V) => string; -}; - -type RemovedSelectorProps = Omit< - SelectorProps, - "loading" | "options" | "onFocus" ->; - -export function AsyncSelector< - V, - T extends Async.Item, - M extends boolean = false ->(props: Override, RemovedSelectorProps>) { - const { label, state, update, ...selector } = props; - - const options = useMemo[]>( - () => - state.content?.map((v) => ({ - label: label(v), - value: v, - })) ?? [], - [state, label] - ); - - return ( - { - if (state.state === "uninitialized") { - update(); - } - }} - {...selector} - > - ); -} - interface AsyncButtonProps { 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; + type: "sonarr" | "radarr" | "bazarr"; onChange?: (path: string) => void; drop?: DropdownProps["drop"]; } export const FileBrowser: FunctionComponent = ({ 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 = ({ return path.slice(0, idx + 1); }, [path]); - const [tree, setTree] = useState([]); - - const requestItems = useMemo(() => { - if (loading) { + const requestItems = () => { + if (isFetching) { return ( @@ -70,19 +70,21 @@ export const FileBrowser: FunctionComponent = ({ const elements = []; - elements.push( - ...tree - .filter((v) => v.name.startsWith(filter)) - .map((v) => ( - - - {v.name} - - )) - ); + if (tree) { + elements.push( + ...tree + .filter((v) => v.name.startsWith(filter)) + .map((v) => ( + + + {v.name} + + )) + ); + } if (elements.length === 0) { elements.push(No Files); @@ -100,7 +102,7 @@ export const FileBrowser: FunctionComponent = ({ } else { return elements; } - }, [tree, filter, previous, loading]); + }; useEffect(() => { if (text === path) { @@ -116,17 +118,6 @@ export const FileBrowser: FunctionComponent = ({ const input = useRef(null); - useEffect(() => { - if (show) { - setLoading(true); - load(path) - .then((res) => { - setTree(res); - }) - .finally(() => setLoading(false)); - } - }, [path, load, show]); - return ( = ({ className="w-100" style={{ maxHeight: 256, overflowY: "auto" }} > - {requestItems} + {requestItems()}
); diff --git a/frontend/src/DisplayItem/generic/blacklist.tsx b/frontend/src/components/inputs/blacklist.tsx similarity index 95% rename from frontend/src/DisplayItem/generic/blacklist.tsx rename to 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 = (props) => { const movie = useModalPayload(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[]>( () => [ @@ -74,33 +73,30 @@ export const MovieHistoryModal: FunctionComponent = (props) => { // Actions accessor: "blacklisted", Cell: ({ row }) => { - const original = row.original; + const { radarrId } = row.original; + const { mutateAsync } = useMovieAddBlacklist(); return ( - MoviesApi.addBlacklist(original.radarrId, form) - } - history={original} + update={history.refetch} + promise={(form) => mutateAsync({ id: radarrId, form })} + history={row.original} > ); }, }, ], - [update] + [history.refetch] ); return ( - - {({ content }) => ( - - )} - + + + ); }; @@ -112,19 +108,9 @@ export const EpisodeHistoryModal: FunctionComponent< > = (props) => { const episode = useModalPayload(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[]>( () => [ @@ -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 ( - EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form) + mutateAsync({ + seriesId: sonarrSeriesId, + episodeId: sonarrEpisodeId, + form, + }) } > ); }, }, ], - [update] + [] ); return ( - - {({ content }) => ( - - )} - + + + ); }; 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) => { const { onSuccess, submit, ...modal } = props; - const profiles = useLanguageProfiles(); + const { data: profiles } = useLanguageProfiles(); const { payload, closeModal } = useModalInformation( modal.modalKey ); - // TODO: Separate movies and series - const hasTask = useIsAnyTaskRunningWithId([GetItemId(payload ?? {})]); + const hasTask = useIsAnyActionRunning(); const profileOptions = useMemo[]>( () => @@ -43,6 +41,10 @@ const Editor: FunctionComponent = (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 { download: (item: T, result: SearchResultType) => Promise; } @@ -55,30 +48,35 @@ export function ManualSearchModal( ) { const { download, ...modal } = props; - const [result, setResult] = useState([]); - const [searchState, setSearchState] = useState(SearchState.Ready); - const item = useModalPayload(modal.modalKey); - const search = useCallback(async () => { + const [episodeId, setEpisodeId] = useState(undefined); + const [radarrId, setRadarrId] = useState(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[]>( () => [ @@ -214,8 +212,8 @@ export function ManualSearchModal( [download, item] ); - const content = useMemo(() => { - if (searchState === SearchState.Ready) { + const content = () => { + if (isInitial) { return (

{item?.path ?? ""}

@@ -224,7 +222,7 @@ export function ManualSearchModal(
); - } else if (searchState === SearchState.Searching) { + } else if (isFetching) { return ; } else { return ( @@ -233,24 +231,21 @@ export function ManualSearchModal( ); } - }, [searchState, columns, result, search, item?.path]); + }; - const footer = useMemo( - () => ( - - ), - [searchState, search] + const footer = ( + ); const title = useMemo(() => { @@ -270,13 +265,13 @@ export function ManualSearchModal( return ( - {content} + {content()} ); } 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 = (props) => { const { payload } = useModalInformation(modal.modalKey); - const profile = useProfileBy(payload?.profileId); + const profile = useLanguageProfileBy(payload?.profileId); const availableLanguages = useProfileItemsToLanguages(profile); @@ -27,6 +30,10 @@ const MovieUploadModal: FunctionComponent = (props) => { return list; }, []); + const { + upload: { mutateAsync }, + } = useMovieSubtitleModification(); + const validate = useCallback>( (item) => { if (item.language === null) { @@ -64,23 +71,20 @@ const MovieUploadModal: FunctionComponent = (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 = ({ }) => { const { payload } = useModalInformation(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[]) => { 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 = ({ 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 = ({ 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>[]>( 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 = ({ 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 = ({ ...props }) => { const closeModal = useCloseModal(); + const { mutateAsync } = useSubtitleAction(); + const process = useCallback( (action: string, override?: Partial) => { log("info", "executing action", action); @@ -318,18 +320,12 @@ const STM: FunctionComponent = ({ ...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 { 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( - entity: Async.Entity, - 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 = TableOptions & - TableStyleProps & { - plugins?: PluginHook[]; - entity: Async.Entity; - loader: (params: Parameter.Range) => void; - }; - -export default function AsyncPageTable(props: Props) { - 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 ; - } - - return ( - - - 0} - canNext={pageIndex < pageCount - 1} - previous={previous} - next={next} - goto={goto} - > - - ); -} 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 = TableOptions & + TableStyleProps & { + plugins?: PluginHook[]; + query: UsePaginationQueryResult; + }; + +export default function QueryPageTable(props: Props) { + 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 ; + } + + return ( + + + + + ); +} 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 { + name: string; + query: UsePaginationQueryResult; + columns: Column[]; +} + +function HistoryView({ + columns, + name, + query, +}: Props) { + return ( + + + {name} History - Bazarr + + + + + + ); +} + +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 { + name: string; + fullQuery: UseQueryResult; + query: UsePaginationQueryResult; + columns: Column[]; + mutation: UseMutationResult; +} + +function ItemView({ + name, + fullQuery, + query, + columns, + mutation, +}: Props) { + const [editMode, setEditMode] = useState(false); + + const { mutateAsync } = mutation; + + const showModal = useShowModal(); + + const updateRow = useCallback>( + ({ original }, modalKey: string) => { + showModal(modalKey, original); + }, + [showModal] + ); + + const options: Partial & TableStyleProps> = { + emptyText: `No ${name} Found`, + update: updateRow, + }; + + const content = editMode ? ( + setEditMode(false)} + > + ) : ( + <> + + setEditMode(true)} + > + Mass Edit + + + + + + + + ); + + return ( + + + {name} - Bazarr + + {content} + + ); +} + +interface ItemMassEditorProps { + columns: Column[]; + query: UseQueryResult; + mutation: UseMutationResult; + onEnded: () => void; +} + +function ItemMassEditor( + props: ItemMassEditorProps +) { + const { columns, mutation, query, onEnded } = props; + const [selections, setSelections] = useState([]); + const [dirties, setDirties] = useState([]); + 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(() => { + const items: JSX.Element[] = []; + if (profiles) { + items.push( + Clear Profile + ); + items.push(); + items.push( + ...profiles.map((v) => ( + + {v.name} + + )) + ); + } + + 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) => { + const id = key ? parseInt(key) : null; + + const newItems = selections.map((v) => ({ ...v, profileId: id })); + + setDirties((dirty) => { + return uniqBy([...newItems, ...dirty], GetItemId); + }); + }, + [selections] + ); + + return ( + <> + + + + + Change Profile + + {profileOptions} + + + + + Cancel + + + Save + + + + + {query.data === undefined ? ( + + ) : ( + + )} + + + ); +} + +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 { + name: string; + columns: Column[]; + query: UsePaginationQueryResult; + searchAll: () => Promise; +} + +const TaskGroupName = "Searching wanted subtitles..."; + +function WantedView({ + name, + columns, + query, + searchAll, +}: Props) { + // TODO + const dataCount = query.paginationStatus.totalCount; + const hasTask = useIsAnyActionRunning(); + + return ( + + + Wanted {name} - Bazarr + + + { + const task = createTask(name, undefined, searchAll); + dispatchTask(TaskGroupName, [task], "Searching..."); + }} + icon={faSearch} + > + Search All + + + + + + + ); +} + +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 = () => ( + + + {/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */} + {/* */} + {Environment.queryDev && } + + {/* */} + + +); -ReactDOM.render(, document.getElementById("root")); +if (!isTestEnv) { + ReactDOM.render(, document.getElementById("root")); +} diff --git a/frontend/src/special-pages/404.tsx b/frontend/src/pages/404.tsx similarity index 100% rename from frontend/src/special-pages/404.tsx rename to frontend/src/pages/404.tsx diff --git a/frontend/src/special-pages/AuthPage.scss b/frontend/src/pages/Authentication.scss similarity index 100% rename from frontend/src/special-pages/AuthPage.scss rename to frontend/src/pages/Authentication.scss diff --git a/frontend/src/special-pages/AuthPage.tsx b/frontend/src/pages/Authentication.tsx similarity index 55% rename from frontend/src/special-pages/AuthPage.tsx rename to 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 = () => { +const Authentication: FunctionComponent = () => { 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 ; } @@ -47,12 +26,7 @@ const AuthPage: FunctionComponent = () => {
{ e.preventDefault(); - if (!updating) { - setUpdate(true); - SystemApi.login(username, password) - .then(onSuccess) - .catch(onError); - } + login({ username, password }); }} > @@ -61,7 +35,7 @@ const AuthPage: FunctionComponent = () => { = () => { = () => { onChange={(e) => setPassword(e.currentTarget.value)} > - + {/*
{error}
-
+
*/}
-