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