mirror of
https://github.com/morpheus65535/bazarr.git
synced 2024-09-20 15:35:58 +08:00
Add React-Query to improve network and cache performance
This commit is contained in:
parent
6b82a734e2
commit
d8d2300980
|
@ -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
|
||||
|
|
203
frontend/package-lock.json
generated
203
frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
|
|
|
@ -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"
|
||||
);
|
|
@ -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"
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
);
|
|
@ -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]);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -1,13 +1,110 @@
|
|||
import movies from "./movie";
|
||||
import series from "./series";
|
||||
import site from "./site";
|
||||
import system from "./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";
|
||||
|
||||
const AllReducers = {
|
||||
movies,
|
||||
series,
|
||||
site,
|
||||
system,
|
||||
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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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({
|
||||
|
|
|
@ -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" },
|
||||
];
|
|
@ -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({});
|
||||
});
|
|
@ -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";
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
2
frontend/src/@types/api.d.ts
vendored
2
frontend/src/@types/api.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
24
frontend/src/@types/async.d.ts
vendored
24
frontend/src/@types/async.d.ts
vendored
|
@ -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[];
|
||||
};
|
||||
}
|
3
frontend/src/@types/function.d.ts
vendored
Normal file
3
frontend/src/@types/function.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
type RangeQuery<T> = (
|
||||
param: Parameter.Range
|
||||
) => Promise<DataWrapperWithTotal<T>>;
|
1
frontend/src/@types/site.d.ts
vendored
1
frontend/src/@types/site.d.ts
vendored
|
@ -8,6 +8,7 @@ declare namespace Server {
|
|||
}
|
||||
|
||||
declare namespace Site {
|
||||
type Status = "uninitialized" | "unauthenticated" | "initialized" | "error";
|
||||
interface Progress {
|
||||
id: string;
|
||||
header: string;
|
||||
|
|
2
frontend/src/@types/utilities.d.ts
vendored
2
frontend/src/@types/utilities.d.ts
vendored
|
@ -29,7 +29,7 @@ interface DataWrapper<T> {
|
|||
data: T;
|
||||
}
|
||||
|
||||
interface AsyncDataWrapper<T> {
|
||||
interface DataWrapperWithTotal<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
|
|
|
@ -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 { data: settings } = useSystemSettings();
|
||||
|
||||
const settings = useSystemSettings();
|
||||
const hasLogout = (settings?.auth.type ?? "none") === "form";
|
||||
|
||||
const canLogout = (settings.content?.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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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];
|
||||
}
|
115
frontend/src/apis/hooks/episodes.ts
Normal file
115
frontend/src/apis/hooks/episodes.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
21
frontend/src/apis/hooks/histories.ts
Normal file
21
frontend/src/apis/hooks/histories.ts
Normal file
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
9
frontend/src/apis/hooks/index.ts
Normal file
9
frontend/src/apis/hooks/index.ts
Normal file
|
@ -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";
|
23
frontend/src/apis/hooks/languages.ts
Normal file
23
frontend/src/apis/hooks/languages.ts
Normal file
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
138
frontend/src/apis/hooks/movies.ts
Normal file
138
frontend/src/apis/hooks/movies.ts
Normal file
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
99
frontend/src/apis/hooks/providers.ts
Normal file
99
frontend/src/apis/hooks/providers.ts
Normal file
|
@ -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]);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
80
frontend/src/apis/hooks/series.ts
Normal file
80
frontend/src/apis/hooks/series.ts
Normal file
|
@ -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]);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
18
frontend/src/apis/hooks/status.ts
Normal file
18
frontend/src/apis/hooks/status.ts
Normal file
|
@ -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;
|
||||
}
|
119
frontend/src/apis/hooks/subtitles.ts
Normal file
119
frontend/src/apis/hooks/subtitles.ts
Normal file
|
@ -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)
|
||||
);
|
||||
}
|
188
frontend/src/apis/hooks/system.ts
Normal file
188
frontend/src/apis/hooks/system.ts
Normal file
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
|
@ -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();
|
116
frontend/src/apis/queries/hooks.ts
Normal file
116
frontend/src/apis/queries/hooks.ts
Normal file
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
14
frontend/src/apis/queries/index.ts
Normal file
14
frontend/src/apis/queries/index.ts
Normal file
|
@ -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;
|
23
frontend/src/apis/queries/keys.ts
Normal file
23
frontend/src/apis/queries/keys.ts
Normal file
|
@ -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",
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
|
@ -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,
|
25
frontend/src/apis/raw/index.ts
Normal file
25
frontend/src/apis/raw/index.ts
Normal file
|
@ -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;
|
|
@ -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", {
|
||||
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) {
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ class SystemApi extends BaseApi {
|
|||
return response;
|
||||
}
|
||||
|
||||
async setSettings(data: object) {
|
||||
async updateSettings(data: object) {
|
||||
await this.post("/settings", data);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
|
@ -1,5 +1,5 @@
|
|||
import UIError from "pages/UIError";
|
||||
import React from "react";
|
||||
import UIError from "./UIError";
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
|
@ -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(() => {
|
|
@ -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[];
|
||||
|
|
|
@ -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 [display, setDisplay] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [debounce] = useThrottle(setQuery, 500);
|
||||
useEffect(() => {
|
||||
debounce(display);
|
||||
}, [debounce, display]);
|
||||
|
||||
const results = useSearch(query);
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const search = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "") {
|
||||
setResults([]);
|
||||
} else {
|
||||
onSearch(value).then((res) => setResults(res));
|
||||
}
|
||||
},
|
||||
[onSearch]
|
||||
);
|
||||
|
||||
const [debounceSearch] = useThrottle(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
debounceSearch(text);
|
||||
}, [text, debounceSearch]);
|
||||
|
||||
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}
|
||||
|
|
|
@ -4,39 +4,36 @@ 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>;
|
||||
children: FunctionComponent<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"];
|
||||
|
|
|
@ -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,6 +70,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
|
|||
|
||||
const elements = [];
|
||||
|
||||
if (tree) {
|
||||
elements.push(
|
||||
...tree
|
||||
.filter((v) => v.name.startsWith(filter))
|
||||
|
@ -83,6 +84,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
|
|||
</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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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 history = useMovieHistory(movie?.radarrId);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (movie) {
|
||||
updateHistory(movie.radarrId);
|
||||
}
|
||||
}, [movie, updateHistory]);
|
||||
|
||||
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 }) => (
|
||||
<QueryOverlay result={history}>
|
||||
<PageTable
|
||||
emptyText="No History Found"
|
||||
columns={columns}
|
||||
data={content?.data ?? []}
|
||||
data={data ?? []}
|
||||
></PageTable>
|
||||
)}
|
||||
</AsyncOverlay>
|
||||
</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 history = useEpisodeHistory(episode?.sonarrEpisodeId);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (episode) {
|
||||
updateHistory(episode.sonarrEpisodeId);
|
||||
}
|
||||
}, [episode, updateHistory]);
|
||||
|
||||
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 }) => (
|
||||
<QueryOverlay result={history}>
|
||||
<PageTable
|
||||
emptyText="No History Found"
|
||||
columns={columns}
|
||||
data={content?.data ?? []}
|
||||
data={data ?? []}
|
||||
></PageTable>
|
||||
)}
|
||||
</AsyncOverlay>
|
||||
</QueryOverlay>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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 () => {
|
||||
if (item) {
|
||||
setSearchState(SearchState.Searching);
|
||||
let results: SearchResultType[] = [];
|
||||
if (isMovie(item)) {
|
||||
results = await ProvidersApi.movies(item.radarrId);
|
||||
} else {
|
||||
results = await ProvidersApi.episodes(item.sonarrEpisodeId);
|
||||
}
|
||||
setResult(results);
|
||||
setSearchState(SearchState.Finished);
|
||||
}
|
||||
}, [item]);
|
||||
const [episodeId, setEpisodeId] = useState<number | undefined>(undefined);
|
||||
const [radarrId, setRadarrId] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (item !== null) {
|
||||
setSearchState(SearchState.Ready);
|
||||
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) {
|
||||
if (isMovie(item)) {
|
||||
setRadarrId(item.radarrId);
|
||||
movies.refetch();
|
||||
} else {
|
||||
setEpisodeId(item.sonarrEpisodeId);
|
||||
episodes.refetch();
|
||||
}
|
||||
}, [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(
|
||||
() => (
|
||||
const footer = (
|
||||
<Button
|
||||
variant="light"
|
||||
hidden={searchState !== SearchState.Finished}
|
||||
hidden={isFetching === true || isInitial === true}
|
||||
onClick={search}
|
||||
>
|
||||
Search Again
|
||||
</Button>
|
||||
),
|
||||
[searchState, search]
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
return createTask(file.name, radarrId, mutateAsync, {
|
||||
radarrId,
|
||||
MoviesApi.uploadSubtitles.bind(MoviesApi),
|
||||
radarrId,
|
||||
{
|
||||
form: {
|
||||
file,
|
||||
forced,
|
||||
hi,
|
||||
language: language!.code2,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
dispatchTask(TaskGroupName, tasks, "Uploading...");
|
||||
},
|
||||
[payload]
|
||||
[mutateAsync, payload]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -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>>[]>(
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
|
77
frontend/src/components/tables/QueryPageTable.tsx
Normal file
77
frontend/src/components/tables/QueryPageTable.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Hooks, TableOptions } from "react-table";
|
||||
import { usePageSize } from "../../../@storage/local";
|
||||
import { usePageSize } from "utilities/storage";
|
||||
|
||||
const pluginName = "useLocalSettings";
|
||||
|
||||
|
|
36
frontend/src/components/views/HistoryView.tsx
Normal file
36
frontend/src/components/views/HistoryView.tsx
Normal file
|
@ -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;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue