Compare commits
573 Commits
v0.0.1
...
76-hostnam
| Author | SHA1 | Date | |
|---|---|---|---|
| dea0031fa9 | |||
| dc7324747f | |||
| fbf6bed4fe | |||
|
|
18b18c3d81 | ||
| a939bf4c71 | |||
| 0a48ecbb2c | |||
| c5dd1abcdd | |||
| 79f9b22fad | |||
|
|
e291b4806b | ||
| ff7b7c9207 | |||
| 2d61fa1436 | |||
| b12849efda | |||
| fb2c969c00 | |||
| d437fddd1a | |||
| 8dd68e9d54 | |||
|
|
a8b52d0ac6 | ||
| 719ebe7c6e | |||
| ddef87ea29 | |||
| 71bad167ab | |||
| df1fbc7dd3 | |||
| 789e5b9518 | |||
| ce6653b5d7 | |||
| d065225d8e | |||
|
|
f854ff9cc6 | ||
| 815444a973 | |||
| 1cbf53be98 | |||
| 0e73f9dad1 | |||
| a15168a6be | |||
|
|
67079f0cb4 | ||
| ef5af01e33 | |||
| 5b6c9c8ffc | |||
| 4556778a47 | |||
| af5f443cbe | |||
| 7babf9f73d | |||
| 1e39daeda2 | |||
| e3cea24615 | |||
| f60c9e4e67 | |||
| 27e6f26331 | |||
| 1f5502ce23 | |||
| 3cb9588bbf | |||
| b1fb256189 | |||
| 74ea2742f0 | |||
| 151c6ec314 | |||
| c6a3be968a | |||
| ff5ce10e17 | |||
| 63e96bf96e | |||
| b699a90a00 | |||
| eda11d3537 | |||
| 262f8d49d7 | |||
| 6b1bb02d57 | |||
| 8d114ff04d | |||
| 9bbf2efc3c | |||
| 5f59456c8b | |||
| ea366d1888 | |||
| 014ea27752 | |||
| 580d19e8a4 | |||
| f146dfdd0b | |||
| 602adf8775 | |||
| fb40fe86b5 | |||
| a3aa46bca3 | |||
| 365fa2e115 | |||
| d2de78968d | |||
|
|
684730f186 | ||
| fad18bc270 | |||
|
|
67d66cefcf | ||
| f865feeeb2 | |||
| 23a0d2c231 | |||
| 2ce7ad2297 | |||
| a10c8b7745 | |||
| 181de14f26 | |||
| 2ab27926f6 | |||
| a4beae5d95 | |||
| ff63944810 | |||
| ab6585ca0c | |||
| 44cf66b6c5 | |||
| 2dea9e1096 | |||
| d2dbb133b3 | |||
| 702f4e62a2 | |||
| 34e475d3cc | |||
| 9cec1c40a8 | |||
| 2244d2f512 | |||
| a46eebb043 | |||
| 7a3e0def34 | |||
| 15c0840c63 | |||
| f308ec0f01 | |||
| a73250d99c | |||
| 2943000db3 | |||
| 5735f09431 | |||
| 8ce44daf9a | |||
| 6b5128ac30 | |||
|
|
6e7b489836 | ||
| ad07d85944 | |||
| 32156a4efc | |||
| aeb6e5225d | |||
| 94eaf8d146 | |||
|
|
cbb579dda4 | ||
| f6d86199dd | |||
| 4ec45352e9 | |||
| a2044941a6 | |||
| 5596da2f2f | |||
|
|
794a34d7f2 | ||
|
|
aba16c3708 | ||
|
|
d50a5ada02 | ||
| a156f07598 | |||
| 33976f0f63 | |||
| d80c672cd1 | |||
| f0505d7428 | |||
| 3173cbf873 | |||
| 9be9750d7b | |||
| 6c673dff2b | |||
| 45be01a140 | |||
| 1a6e28e55d | |||
| b754b75eb6 | |||
| 8f79da2eab | |||
|
|
2b74aaa8f5 | ||
|
|
a64a8b5410 | ||
| 6b508d4c36 | |||
|
|
30ee5e4a67 | ||
|
|
5e7496028f | ||
| a0d29b5086 | |||
|
|
349e74b123 | ||
| c3d8a3db74 | |||
| bb54f3e107 | |||
| 1fab50a92a | |||
| e243d7c795 | |||
|
|
ee5ba474ee | ||
| 52daf4781b | |||
| 2b4f56d51c | |||
| 544d359501 | |||
|
|
a40b08c990 | ||
| ab19f37007 | |||
| d17f49baf4 | |||
| 253c7357a0 | |||
| 423aacca2a | |||
| 90889ea2f1 | |||
| 8927856116 | |||
| 9d4faccb35 | |||
| 241be87ec0 | |||
| 2cdf81fc04 | |||
| f4b2ae2bff | |||
|
|
f4f39d4ec9 | ||
| 32125c074d | |||
| 31fd22a291 | |||
| 3d1a664d49 | |||
|
|
9e9c2849b5 | ||
| ce683feb11 | |||
| 2990fa354f | |||
| f94540616d | |||
| 312ee93781 | |||
| 771400fdf3 | |||
| 6a8d729ad9 | |||
| 6d89b94425 | |||
| 115571e297 | |||
| 93bd0f949c | |||
|
|
ec5427f53b | ||
| 99f6991896 | |||
| 1f26fe5cfa | |||
| c4f46cc727 | |||
| a3f076add3 | |||
| c91f64239a | |||
| 0a315b1ef9 | |||
| 81bdaefdd1 | |||
| 0cd142153c | |||
| 0d0fd948b5 | |||
| 89ca89752c | |||
| ff47e3d590 | |||
| 5bf9e88b41 | |||
| c208cbb76f | |||
| 3d2fe78657 | |||
| ddd9fe958e | |||
| 55f4e0ccec | |||
| 6dcdbd9227 | |||
| 7e7e042591 | |||
| 5aef96a5fb | |||
| 36f743362c | |||
| a0292d688a | |||
|
|
aece7fc7d1 | ||
| 8a79f6925a | |||
| 7b1dc56dbb | |||
| 03b982858d | |||
| 089078fda0 | |||
| 2c8e339e3b | |||
| 4e2a91a1ce | |||
| b1b4070867 | |||
| f6ca91f530 | |||
| f28fbfa938 | |||
| d6782bcf5d | |||
|
|
257edbc8de | ||
| b237d2a32d | |||
| bb3e01ca24 | |||
| a7a536c647 | |||
| cf167be8da | |||
|
|
c5e369f42b | ||
| 103cc7fa91 | |||
| 9ec5040bd7 | |||
| 769e2e3edc | |||
| 9b8f66c8b2 | |||
| 6094c2489c | |||
| ffb5d73ab8 | |||
| e4b04c51eb | |||
| 50f59f9493 | |||
|
|
afb015fd7b | ||
| b80e9acbcc | |||
|
|
0e41b16cb2 | ||
| 262a31e3a5 | |||
| 822e43810c | |||
|
|
758bb4e67e | ||
| 573df70240 | |||
| fe1b55a35d | |||
| 36f41ef168 | |||
| 3911a54e94 | |||
| 829bc488c8 | |||
| 635e70ba81 | |||
| 8ff12e5e02 | |||
|
|
82719d24b8 | ||
| 1fbf677806 | |||
| ce392ec13e | |||
| d1a0bc7d46 | |||
| 033c59e2d0 | |||
| df2d336b48 | |||
| 7a10fb4d35 | |||
| 1bbf5db6a8 | |||
| 9ff048d541 | |||
| 8fdd8d0226 | |||
|
|
2738ae54f1 | ||
| cf1fe451c9 | |||
| c6265599de | |||
| bcfc174829 | |||
| 1d317abbdb | |||
| d819bac7f2 | |||
| 3cedb9238b | |||
| 27bd383f00 | |||
| e9e7ff7e5f | |||
| 8f83a0f94f | |||
| 8e5ff81d5f | |||
| 30733dc62d | |||
| ca6b030746 | |||
| 3876037a7f | |||
| 7769ed2776 | |||
| 61556c958b | |||
|
|
acffccfc18 | ||
| 1722b7c812 | |||
|
|
75bbc9851b | ||
| 762db13b4f | |||
| fe3e294584 | |||
| ebd7891cdf | |||
| de0c5a462a | |||
| a9df8db867 | |||
| a411b696f5 | |||
| 7bb5669c52 | |||
| 0bfe580389 | |||
| ea6b2013f5 | |||
| 68a0c816ec | |||
| e4826c3272 | |||
| 8644b79b75 | |||
| 2c0664506e | |||
| ac563b9ce9 | |||
| 296ebafd5f | |||
| cc317196ba | |||
| a964ffbf07 | |||
| 41918daafa | |||
| 3017920fb7 | |||
| 1bf4fd3423 | |||
| 3ee2527b75 | |||
| 9b65c0a97a | |||
| 03c24328ed | |||
| 1b57092d3b | |||
| e6952b44bf | |||
| 1974e8d857 | |||
| 2656646412 | |||
| f371858a0c | |||
| ffeb434075 | |||
| edf49527a0 | |||
| e197143498 | |||
| 69ccbd3b55 | |||
| 2cc9aee22e | |||
| 0c01d11b44 | |||
| 95fe37e542 | |||
| 25d68edbc4 | |||
| 2d75ba25bd | |||
| 485273da21 | |||
| ce5ad3575e | |||
| bde76a81b7 | |||
| 2941559a18 | |||
| 5374f38b62 | |||
| 84f242184e | |||
| 610038247f | |||
| 5251803233 | |||
| 725c156e88 | |||
| 2104f12e8f | |||
| ba6acaac08 | |||
| 925008bdcb | |||
| 6e1b431600 | |||
| b34c985ff4 | |||
| 305c172be7 | |||
| ced3457ea2 | |||
| ae3711f321 | |||
| 4f95a9712d | |||
| 245dd7a7c1 | |||
| 9e39448545 | |||
| c5845a55cb | |||
| 68fcd4c3ce | |||
| 245d9e9050 | |||
| 7c368b9e54 | |||
| 5545d8bbf5 | |||
| e42e67d9ab | |||
| 0cf76193e4 | |||
| 4cc8e92f95 | |||
| 6babcc4c6a | |||
| b7ad2d1634 | |||
| 9d6a43a7aa | |||
| b13354c9f7 | |||
| b98ad28fbe | |||
| e4750cea2f | |||
| ab09335cfb | |||
| feb4bbc3b6 | |||
| 10e5ca3245 | |||
| 4a108987a8 | |||
| 10b284c96c | |||
| 59f5f42107 | |||
| dbd133ed8d | |||
| bb10b4a364 | |||
| beb39fbf31 | |||
| 1e74363411 | |||
| 3f92b36d55 | |||
| 5b59758654 | |||
| 8f23b5a251 | |||
| 0d5f6d9228 | |||
| f38fe2ded3 | |||
| b8c932b09f | |||
| c7a525da0a | |||
| e46c5e02b2 | |||
| fd948bd2eb | |||
| 3da522c9c0 | |||
| 0ee372fc5d | |||
| e1635ff258 | |||
| 5ce04317a6 | |||
| 4993269540 | |||
| f1f4375af2 | |||
| d1e81b24e4 | |||
| 64f8cbeadb | |||
| 23a5cb383e | |||
| a383737535 | |||
| d27f4dbe06 | |||
| 51117c2785 | |||
| d2d21fc4f8 | |||
| c7286f827b | |||
| 25bd822b46 | |||
| f41cfd505e | |||
| c6e755eca6 | |||
| 4209012298 | |||
| 95a5a9bfa6 | |||
| 6e3594f71b | |||
| 50c299a8c4 | |||
| 616e3e5866 | |||
| efe5416e5f | |||
| d90bf44210 | |||
| 0a11a1862d | |||
| 5557b2a119 | |||
| 95ee4d7a40 | |||
| 195d9431c4 | |||
| 464e7da826 | |||
| 9003124032 | |||
| bc053c3862 | |||
| 4e960d272e | |||
| 434fbd782f | |||
| 1576ada32c | |||
| 08811e5128 | |||
| 124a88cb2c | |||
| 5964221c61 | |||
| cdbe39c0c7 | |||
| a18705dab8 | |||
| 8fc8bf7248 | |||
| 843dee30a4 | |||
| 2f859a7250 | |||
| 2e5214338c | |||
| 2406c68216 | |||
| 086cdb3817 | |||
| e70994c032 | |||
| f5879e25fe | |||
| 881b19fb18 | |||
| 56449b3ef6 | |||
| acca2037b4 | |||
| e1504c2f11 | |||
| 56d22a28a0 | |||
| 8fbea27fb2 | |||
| d520f897de | |||
| 0df9b48703 | |||
| 48213592f0 | |||
| 824ff7ff9e | |||
| 1ffc725252 | |||
| 5f0e2ec06f | |||
| 3983466105 | |||
| 116cbf2a62 | |||
| 419962c756 | |||
| 31a72e4b6f | |||
| 8881576296 | |||
| 7531d56c6a | |||
| f341529e6c | |||
| 261e3ee882 | |||
| 705ccaa51d | |||
| 343dbf075d | |||
| fda7a7e36e | |||
| 5dbe7afd5e | |||
| 7b10f28ee0 | |||
| be370d2a46 | |||
| eed908b9cf | |||
| a51d7a602c | |||
| 9024c3cce9 | |||
| c1e9a30419 | |||
| b1ff7e78ba | |||
| c3a91ef39f | |||
| 98e49a6e73 | |||
| 2cc8f9da87 | |||
| 984e0c4bb1 | |||
| 04968a6031 | |||
| 41ab6fb9d5 | |||
| d5c9d842a3 | |||
| 857e8b58e0 | |||
| eaf5616fea | |||
| 918b4dd47d | |||
| d84b02ef82 | |||
| 84e2a10d1b | |||
| 2d1ccf7264 | |||
| 8b3743fe6b | |||
| a050a83724 | |||
| 9df5adb69c | |||
| e9d744f4f5 | |||
| f5116e1278 | |||
| 303a2cd54b | |||
| 492fd572e2 | |||
| ed120fb230 | |||
| 049c4266f3 | |||
| 972ba48e6f | |||
| 86b9ec9d95 | |||
| e878daa114 | |||
| cfd29cb4cd | |||
| c8eb2882bd | |||
| 6c1a068d5e | |||
| 86b4223c3a | |||
| 490587735b | |||
| 83b9a01d86 | |||
| c96ab4ee4a | |||
| 4a5551a8f2 | |||
| b2bcfd5422 | |||
| 809b753b41 | |||
| 9121e94e91 | |||
| d0e3332c2c | |||
| d622111a73 | |||
| 5c39de3280 | |||
| 7349eb05a3 | |||
| bce37906e9 | |||
| 9ff79320da | |||
| 53b7e94135 | |||
| 63fecd4592 | |||
| 478e105e48 | |||
| 2a81bfcc2e | |||
| 3051680a85 | |||
| fce8551731 | |||
| 339af88a5b | |||
| eba51803f6 | |||
| eb85ede5d8 | |||
| 9025085447 | |||
| 0a4c8fab81 | |||
| 75d5dc4281 | |||
| ebd5a8b95d | |||
| c134d2fc49 | |||
| b87a13d006 | |||
| 8db52e0407 | |||
| ca082b8220 | |||
| 7615e1fe52 | |||
| 9090ef2663 | |||
| 51e9397055 | |||
| 2ba6e23efa | |||
| bbde67bb11 | |||
| 99e25b6cbb | |||
| 6838e3c767 | |||
| 5ffa29a718 | |||
| 49d0533a5d | |||
|
|
88f20becf3 | ||
| 384f1ce81e | |||
| 048c7ba6d5 | |||
| a7afc358d8 | |||
| 3aa5ea3427 | |||
| 873fa28391 | |||
| 7d56be71f9 | |||
| 8e518c93a8 | |||
| be43c163dc | |||
| 476a55614e | |||
| b40f63289a | |||
| ab8a3740b5 | |||
| 99e1ce8b4f | |||
| 25150fac3a | |||
| 3add60276b | |||
| c3587df058 | |||
| 757b03e3a6 | |||
| 0e039fb0a8 | |||
| e95eeb5f27 | |||
| 0e085f6f06 | |||
| ed0d7cd254 | |||
| 67c3ab807c | |||
| f04641986d | |||
| f3d94f2a75 | |||
| 3c58795286 | |||
| efc8742699 | |||
| 060a9143e8 | |||
| a173b4f971 | |||
| c8061e0d5c | |||
| 664f359ef7 | |||
| bacde33567 | |||
| cdd27a1272 | |||
| c098912d6e | |||
| e202ae45a4 | |||
| 1f8e0d6ff6 | |||
| 3fc22c74ef | |||
| c02b512e1c | |||
| ae7a78de4d | |||
| 4a4e04ff2b | |||
| 96e8e1fb17 | |||
| 7b29897c9f | |||
| 66aac9e35c | |||
| 762b5cb037 | |||
| cc8d3febdb | |||
|
|
b18418f9ae | ||
| ec914167e7 | |||
| 8f3fda5551 | |||
| 86d029ffba | |||
| b3799edcc6 | |||
| 632b2dc267 | |||
| 5141d387b7 | |||
| d1fb000cf0 | |||
| ee30a11123 | |||
| 482e890d95 | |||
| 4ebe543f6a | |||
| 2960c2dca4 | |||
| 6994da072d | |||
| 140f690c77 | |||
| 8a1e6fb98a | |||
| e5870fa1a8 | |||
| 0a269b33fb | |||
| 9440cafe86 | |||
| 38fc53c8c3 | |||
| f6650fc18d | |||
| 7aa3db125b | |||
| 2184b20887 | |||
| d7f0bdcdfe | |||
| d9003bf2c7 | |||
| 9b880a2749 | |||
| afdff65b6b | |||
| d92b2246cb | |||
| 7c8ee36505 | |||
| a20da523b2 | |||
| 9aee8176b0 | |||
| d25720caba | |||
| 0baf3e436f | |||
| a2768d18e9 | |||
| 2226f5cc03 | |||
| 4080572c44 | |||
| 3c8a330336 | |||
| 2a2d996d54 | |||
| 49759929af | |||
| b2fb21146d | |||
| 8df48ea6ae | |||
| 05fb0fad2b | |||
| 0de430f826 | |||
| 7eed36134e | |||
|
|
f22e7e52be | ||
| 24eaeff9bc | |||
| 7c3035fcd5 | |||
| 802dee8857 | |||
| ae5f0b2c0f | |||
| 5a7f30f007 | |||
| 57f7621c0e |
17
.babelrc
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
"@babel/preset-env",
|
|
||||||
"@babel/preset-react",
|
|
||||||
"@babel/preset-typescript"
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"react-hot-loader/babel",
|
|
||||||
"@babel/transform-runtime",
|
|
||||||
"@babel/plugin-proposal-class-properties"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"production": {
|
|
||||||
"presets": ["minify"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
comics
|
||||||
|
userdata
|
||||||
28
.eslintrc.js
@@ -1,30 +1,28 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: [
|
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
|
||||||
"plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react
|
|
||||||
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
|
||||||
"plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors.
|
|
||||||
],
|
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
jsx: true, // Allows for the parsing of JSX
|
jsx: true // Allows for the parsing of JSX
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
|
plugins: ["@typescript-eslint", "css-modules"],
|
||||||
settings: {
|
settings: {
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
node: {
|
node: {
|
||||||
extensions: [".js", ".jsx", ".ts", ".tsx"],
|
extensions: [".js", ".jsx", ".ts", ".tsx"]
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
react: {
|
react: {
|
||||||
version: "detect", // Tells eslint-plugin-react to automatically detect the version of React to use
|
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fine tune rules
|
// Fine tune rules
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-var-requires": 0,
|
"@typescript-eslint/no-var-requires": 0
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
20
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- name: Publish to Registry
|
||||||
|
uses: elgohr/Publish-Docker-Github-Action@v5
|
||||||
|
with:
|
||||||
|
name: frishi/threetwo
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
10
.gitignore
vendored
@@ -5,9 +5,17 @@ docs/
|
|||||||
userdata/
|
userdata/
|
||||||
dist/
|
dist/
|
||||||
src/client/assets/scss/App.css
|
src/client/assets/scss/App.css
|
||||||
server/
|
/server/
|
||||||
node_modules/
|
node_modules/
|
||||||
src/**/*.jsx
|
src/**/*.jsx
|
||||||
tests/__coverage__/
|
tests/__coverage__/
|
||||||
tests/**/*.jsx
|
tests/**/*.jsx
|
||||||
src/client/assets/scss/App.css.map
|
src/client/assets/scss/App.css.map
|
||||||
|
yarn-error.log
|
||||||
|
.nova
|
||||||
|
environment.list
|
||||||
|
.env
|
||||||
|
src/client/assets/img/missing-file.pxd
|
||||||
|
*.pxd
|
||||||
|
.parcel-cache
|
||||||
|
src/stories
|
||||||
|
|||||||
17
.storybook/main.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||||
|
addons: [
|
||||||
|
"@storybook/addon-links",
|
||||||
|
"@storybook/addon-essentials",
|
||||||
|
"@storybook/addon-interactions",
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: "@storybook/react-vite",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: "tag",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
3
.storybook/preview-head.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
||||||
18
.storybook/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Preview } from "@storybook/react";
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
backgrounds: {
|
||||||
|
default: "light",
|
||||||
|
},
|
||||||
|
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
45
.yarnclean
@@ -1,45 +0,0 @@
|
|||||||
# test directories
|
|
||||||
__tests__
|
|
||||||
test
|
|
||||||
tests
|
|
||||||
powered-test
|
|
||||||
|
|
||||||
# asset directories
|
|
||||||
docs
|
|
||||||
doc
|
|
||||||
website
|
|
||||||
images
|
|
||||||
assets
|
|
||||||
|
|
||||||
# examples
|
|
||||||
example
|
|
||||||
examples
|
|
||||||
|
|
||||||
# code coverage directories
|
|
||||||
coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# build scripts
|
|
||||||
Makefile
|
|
||||||
Gulpfile.js
|
|
||||||
Gruntfile.js
|
|
||||||
|
|
||||||
# configs
|
|
||||||
appveyor.yml
|
|
||||||
circle.yml
|
|
||||||
codeship-services.yml
|
|
||||||
codeship-steps.yml
|
|
||||||
wercker.yml
|
|
||||||
.tern-project
|
|
||||||
.gitattributes
|
|
||||||
.editorconfig
|
|
||||||
.*ignore
|
|
||||||
.eslintrc
|
|
||||||
.jshintrc
|
|
||||||
.flowconfig
|
|
||||||
.documentup.json
|
|
||||||
.yarn-metadata.json
|
|
||||||
.travis.yml
|
|
||||||
|
|
||||||
# misc
|
|
||||||
*.md
|
|
||||||
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:18.15.0-alpine
|
||||||
|
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||||
|
|
||||||
|
WORKDIR /threetwo
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
COPY yarn.lock ./
|
||||||
|
COPY nodemon.json ./
|
||||||
|
COPY jsdoc.json ./
|
||||||
|
|
||||||
|
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
|
||||||
|
RUN apk --no-cache add g++ make libpng-dev git python3 libc6-compat autoconf automake libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||||
|
RUN yarn --ignore-engines
|
||||||
|
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3050
|
||||||
|
|
||||||
|
ENTRYPOINT [ "npm", "start" ]
|
||||||
62
README.md
@@ -0,0 +1,62 @@
|
|||||||
|
# ThreeTwo!
|
||||||
|
|
||||||
|
ThreeTwo! _aims to be_ a comic book curation app.
|
||||||
|
|
||||||
|
[](https://github.com/rishighan/threetwo/actions/workflows/docker-image.yml)
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### 🦄 Early Development Support Channel
|
||||||
|
|
||||||
|
Please help me test the early builds of `ThreeTwo!` on its official [Discord](https://discord.gg/n4HZ4j33uT)
|
||||||
|
|
||||||
|
Discuss ideas and implementations with me, and get status, progress updates!
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
ThreeTwo! currently is set up as:
|
||||||
|
|
||||||
|
1. The UI, this repo.
|
||||||
|
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
|
||||||
|
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
|
||||||
|
4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
|
||||||
|
|
||||||
|
## Docker Instructions
|
||||||
|
|
||||||
|
See [threetwo-docker-compose](https://github.com/rishighan/threetwo-docker-compose) for instructions on building the entire stack.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
For debugging and troubleshooting, you can run this app locally using these steps:
|
||||||
|
|
||||||
|
1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git`
|
||||||
|
2. `yarn run dev` (you can ignore the warnings)
|
||||||
|
3. This will open `http://localhost:5173` in your default browser
|
||||||
|
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
|
||||||
|
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
1. `docker-compose up` is taking a long time
|
||||||
|
|
||||||
|
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
|
||||||
|
|
||||||
|
2. What folder do my comics go in?
|
||||||
|
|
||||||
|
Your comics go in the `comics` directory at the root of this project.
|
||||||
|
|
||||||
|
|
||||||
|
## Contribution Guidelines
|
||||||
|
|
||||||
|
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)
|
||||||
|
|
||||||
|
|||||||
0
contributing.md
Normal file
1
funding.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: [rishighan]
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/client/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
20
jsdoc.json
@@ -1,7 +1,15 @@
|
|||||||
{
|
{
|
||||||
"tags": { "allowUnknownTags": true },
|
"tags": {
|
||||||
|
"allowUnknownTags": true,
|
||||||
|
"dictionaries": [
|
||||||
|
"jsdoc",
|
||||||
|
"closure"
|
||||||
|
]
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"include": ["./src/"],
|
"include": [
|
||||||
|
"./src/client"
|
||||||
|
],
|
||||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
@@ -10,7 +18,11 @@
|
|||||||
"plugins/markdown",
|
"plugins/markdown",
|
||||||
"node_modules/better-docs/typescript"
|
"node_modules/better-docs/typescript"
|
||||||
],
|
],
|
||||||
"templates": { "better-docs": { "name": "My React components" } },
|
"templates": {
|
||||||
|
"better-docs": {
|
||||||
|
"name": "ThreeTwo UI components"
|
||||||
|
}
|
||||||
|
},
|
||||||
"opts": {
|
"opts": {
|
||||||
"destination": "docs/",
|
"destination": "docs/",
|
||||||
"readme": "README.md",
|
"readme": "README.md",
|
||||||
@@ -19,4 +31,4 @@
|
|||||||
"verbose": true,
|
"verbose": true,
|
||||||
"template": "node_modules/better-docs"
|
"template": "node_modules/better-docs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
186
package.json
@@ -5,146 +5,136 @@
|
|||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"typings": "server/index.js",
|
"typings": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production",
|
"build": "vite build",
|
||||||
"start": "npm run build && npm run server",
|
"dev": "rimraf dist && npm run build && vite",
|
||||||
"client": "webpack serve --mode development --devtool inline-source-map --hot",
|
"start": "npm run build && vite",
|
||||||
"server": "tsc -p tsconfig.server.json && node server/",
|
"docs": "jsdoc -c jsdoc.json",
|
||||||
"dev": "concurrently \"nodemon\" \"npm run client\"",
|
"storybook": "storybook dev -p 6006",
|
||||||
"server-dev": "nodemon",
|
"build-storybook": "storybook build"
|
||||||
"docs": "jsdoc -c jsdoc.json"
|
|
||||||
},
|
},
|
||||||
"author": "Rishi Ghan",
|
"author": "Rishi Ghan",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.13.17",
|
"@babel/runtime": "^7.13.17",
|
||||||
"@bluelovers/fast-glob": "^3.0.4",
|
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
|
||||||
"@types/event-stream": "^3.3.34",
|
"@dnd-kit/core": "^4.0.0",
|
||||||
|
"@dnd-kit/sortable": "^5.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.0",
|
||||||
|
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||||
|
"@redux-devtools/extension": "^3.2.2",
|
||||||
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
|
"@tanstack/react-table": "^8.5.11",
|
||||||
|
"@types/axios": "^0.14.0",
|
||||||
"@types/mime-types": "^2.1.0",
|
"@types/mime-types": "^2.1.0",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/react-dom": "^17.0.2",
|
|
||||||
"@types/react-redux": "^7.1.16",
|
|
||||||
"@types/react-router-dom": "^5.1.7",
|
|
||||||
"@types/sharp": "^0.28.0",
|
|
||||||
"@types/socket.io": "^3.0.2",
|
"@types/socket.io": "^3.0.2",
|
||||||
"@types/socket.io-client": "^3.0.0",
|
"@types/socket.io-client": "^3.0.0",
|
||||||
"@types/through2": "^2.0.36",
|
"@vitejs/plugin-react": "^3.1.0",
|
||||||
"airdcpp-apisocket": "^2.4.1",
|
"airdcpp-apisocket": "2.4.5-beta.1",
|
||||||
"antd": "^4.16.5",
|
"axios": "^1.3.4",
|
||||||
|
"axios-cache-interceptor": "^1.0.1",
|
||||||
|
"axios-rate-limit": "^1.3.0",
|
||||||
"babel-polyfill": "^6.26.0",
|
"babel-polyfill": "^6.26.0",
|
||||||
"better-docs": "^2.3.2",
|
"babel-preset-minify": "^0.5.2",
|
||||||
"calibre-opds": "^1.0.7",
|
"better-docs": "^2.7.2",
|
||||||
"comlink-loader": "^2.0.0",
|
"date-fns": "^2.28.0",
|
||||||
"ellipsize": "^0.1.0",
|
"dayjs": "^1.10.6",
|
||||||
"event-stream": "^4.0.1",
|
"ellipsize": "^0.5.1",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"fastest-validator": "^1.11.0",
|
"filename-parser": "^1.0.2",
|
||||||
"final-form": "^4.20.2",
|
"final-form": "^4.20.2",
|
||||||
"fs-extra": "^9.1.0",
|
"final-form-arrays": "^3.0.2",
|
||||||
"http-response-stream": "^1.0.7",
|
"history": "^5.3.0",
|
||||||
"imghash": "^0.0.8",
|
"html-to-text": "^8.1.0",
|
||||||
"jsdoc": "^3.6.7",
|
"jsdoc": "^3.6.10",
|
||||||
"opds-extra": "^3.0.9",
|
"lodash": "^4.17.21",
|
||||||
|
"node-sass": "npm:sass",
|
||||||
"pretty-bytes": "^5.6.0",
|
"pretty-bytes": "^5.6.0",
|
||||||
"react": "^17.0.1",
|
"prop-types": "^15.8.1",
|
||||||
"react-collapsible": "^2.8.3",
|
"qs": "^6.10.5",
|
||||||
"react-dom": "^17.0.1",
|
"react": "^18.2.0",
|
||||||
"react-final-form": "^6.5.3",
|
"react-collapsible": "^2.9.0",
|
||||||
"react-spinners": "^0.11.0",
|
"react-comic-viewer": "^0.4.0",
|
||||||
"react-window-dynamic-list": "^2.3.5",
|
"react-day-picker": "^8.6.0",
|
||||||
"sharp": "^0.28.1",
|
"react-dom": "^18.1.0",
|
||||||
"socket.io-client": "^4.1.2",
|
"react-fast-compare": "^3.2.0",
|
||||||
"threetwo-ui-typings": "^1.0.1",
|
"react-final-form": "^6.5.9",
|
||||||
"voca": "^1.4.0",
|
"react-final-form-arrays": "^3.1.4",
|
||||||
|
"react-loader-spinner": "^4.0.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
|
"react-modal": "^3.15.1",
|
||||||
|
"react-redux": "^8.0.5",
|
||||||
|
"react-router": "^6.9.0",
|
||||||
|
"react-router-dom": "^6.9.0",
|
||||||
|
"react-select": "^5.3.2",
|
||||||
|
"react-select-async-paginate": "^0.7.2",
|
||||||
|
"react-slick": "^0.29.0",
|
||||||
|
"react-sliding-pane": "^7.1.0",
|
||||||
|
"react-stickynode": "^4.1.0",
|
||||||
|
"react-textarea-autosize": "^8.3.4",
|
||||||
|
"reapop": "^4.2.1",
|
||||||
|
"redux-first-history": "^5.1.1",
|
||||||
|
"redux-socket.io-middleware": "^1.0.4",
|
||||||
|
"redux-thunk": "^2.4.2",
|
||||||
|
"slick-carousel": "^1.8.1",
|
||||||
|
"socket.io-client": "^4.3.2",
|
||||||
|
"styled-components": "^5.3.9",
|
||||||
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
|
"vite-plugin-html": "^3.2.0",
|
||||||
"websocket": "^1.0.34",
|
"websocket": "^1.0.34",
|
||||||
"ws": "^7.5.3",
|
|
||||||
"ws-calibre": "bluelovers/ws-calibre",
|
|
||||||
"xregexp": "^5.0.2"
|
"xregexp": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.13.10",
|
"@babel/cli": "^7.13.10",
|
||||||
"@babel/core": "^7.13.10",
|
"@babel/core": "^7.13.10",
|
||||||
|
"@babel/plugin-syntax-top-level-await": "^7.14.5",
|
||||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||||
"@babel/preset-env": "^7.13.10",
|
"@babel/preset-env": "^7.20.2",
|
||||||
"@babel/preset-react": "^7.12.13",
|
"@babel/preset-react": "^7.18.6",
|
||||||
"@babel/preset-typescript": "^7.13.0",
|
"@babel/preset-typescript": "^7.13.0",
|
||||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
"@storybook/addon-essentials": "^7.0.0-rc.3",
|
||||||
"@root/walk": "^1.1.0",
|
"@storybook/addon-interactions": "^7.0.0-rc.3",
|
||||||
|
"@storybook/addon-links": "^7.0.0-rc.3",
|
||||||
|
"@storybook/blocks": "^7.0.0-rc.3",
|
||||||
|
"@storybook/react": "^7.0.0-rc.3",
|
||||||
|
"@storybook/react-vite": "^7.0.0-rc.3",
|
||||||
|
"@storybook/testing-library": "^0.0.14-next.1",
|
||||||
"@tsconfig/node14": "^1.0.0",
|
"@tsconfig/node14": "^1.0.0",
|
||||||
|
"@types/ellipsize": "^0.1.1",
|
||||||
"@types/express": "^4.17.8",
|
"@types/express": "^4.17.8",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/lodash": "^4.14.168",
|
"@types/lodash": "^4.14.168",
|
||||||
"@types/mongoose": "^5.7.37",
|
|
||||||
"@types/node": "^14.14.34",
|
"@types/node": "^14.14.34",
|
||||||
"@types/pino": "^6.3.7",
|
"@types/react": "^18.0.28",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@types/react-dom": "^17.0.2",
|
"@types/react-redux": "^7.1.25",
|
||||||
"@types/react-redux": "^7.1.16",
|
|
||||||
"@types/unzipper": "^0.10.3",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||||
"@typescript-eslint/parser": "^4.17.0",
|
"@typescript-eslint/parser": "^4.17.0",
|
||||||
"awesome-typescript-loader": "^5.2.1",
|
|
||||||
"axios": "^0.21.1",
|
|
||||||
"axios-rate-limit": "^1.3.0",
|
|
||||||
"babel-eslint": "^10.0.0",
|
"babel-eslint": "^10.0.0",
|
||||||
"babel-loader": "^8.2.2",
|
|
||||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"buffer": "^6.0.3",
|
"bulma": "^0.9.4",
|
||||||
"bulma": "^0.9.3",
|
|
||||||
"clean-webpack-plugin": "^1.0.0",
|
|
||||||
"comlink": "^4.3.0",
|
|
||||||
"compromise": "^13.10.5",
|
|
||||||
"compromise-dates": "^2.0.1",
|
|
||||||
"compromise-numbers": "^1.2.0",
|
|
||||||
"compromise-sentences": "^0.2.0",
|
|
||||||
"concurrently": "^4.0.0",
|
|
||||||
"connected-react-router": "^6.9.1",
|
|
||||||
"css-loader": "^5.1.2",
|
|
||||||
"eslint": "^7.22.0",
|
"eslint": "^7.22.0",
|
||||||
"eslint-config-airbnb": "^18.2.1",
|
"eslint-config-airbnb": "^18.2.1",
|
||||||
"eslint-config-airbnb-base": "^14.2.1",
|
"eslint-config-airbnb-base": "^14.2.1",
|
||||||
"eslint-config-prettier": "^8.1.0",
|
"eslint-config-prettier": "^8.1.0",
|
||||||
|
"eslint-plugin-css-modules": "^2.11.0",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"eslint-plugin-import": "^2.22.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
"etl": "^0.6.12",
|
"eslint-plugin-storybook": "^0.6.11",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"html-webpack-plugin": "^5.3.1",
|
|
||||||
"image-webpack-loader": "^7.0.1",
|
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"mini-css-extract-plugin": "^1.4.1",
|
|
||||||
"mongoose": "^5.10.11",
|
|
||||||
"node-sass": "^5.0.0",
|
|
||||||
"node-unrar-js": "^1.0.1",
|
|
||||||
"nodemon": "^1.17.3",
|
"nodemon": "^1.17.3",
|
||||||
"npm": "^7.9.0",
|
"npm": "^8.11.0",
|
||||||
"pino": "^6.11.2",
|
|
||||||
"pino-pretty": "^4.7.1",
|
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"qs": "^6.10.1",
|
"react-refresh": "^0.14.0",
|
||||||
"react": "^17.0.1",
|
"rimraf": "^4.1.3",
|
||||||
"react-dom": "^17.0.1",
|
"sass": "^1.58.1",
|
||||||
"react-hot-loader": "^4.13.0",
|
"storybook": "^7.0.0-rc.3",
|
||||||
"react-redux": "^7.2.3",
|
|
||||||
"react-router": "^5.2.0",
|
|
||||||
"react-router-dom": "^5.2.0",
|
|
||||||
"redux-thunk": "^2.3.0",
|
|
||||||
"rimraf": "^3.0.2",
|
|
||||||
"sass-loader": "^11.0.1",
|
|
||||||
"source-map-loader": "^0.2.4",
|
|
||||||
"string-similarity": "^4.0.4",
|
|
||||||
"style-loader": "^2.0.0",
|
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
"typescript": "^4.2.3",
|
"typescript": "^5.0.2",
|
||||||
"unzipper": "^0.10.11",
|
"vite": "^4.2.0"
|
||||||
"url-loader": "^1.0.1",
|
|
||||||
"webpack": "^5.33.2",
|
|
||||||
"webpack-cli": "^4.6.0",
|
|
||||||
"webpack-dev-server": "^3.11.2",
|
|
||||||
"webpack-merge": "^5.7.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,159 +1,17 @@
|
|||||||
# Client side boilerplate with ReactJS library and Typescript
|
## ThreeTwo UI
|
||||||
|
|
||||||
## Introduction
|
##### I have tried my best to document the project through folder organization, comments, and actual JSDocs where applicable. Unit tests and I have not agreed for a long time now, and I think it won't change anytime soon.
|
||||||
|
|
||||||
In the client side boilerplate, Typescript has been used to achieve a more structured and maintainable source code. ReactJS library which is one of the most important libraries for UI development alongside the other big names in the market, has been picked over to build the presentation layer of the application. Also for CSS, Less has been used to make CSS more functional.
|
|
||||||
|
|
||||||
### Less
|
This folder houses all the components, utils and libraries that make up ThreeTwo's UI
|
||||||
|
|
||||||
[Less](http://lesscss.org/) is a backwards-compatible language extension for CSS. Less helps to write CSS in a functional way and It's really easy to read and understand.
|
It is based on React 18, and uses:
|
||||||
|
|
||||||
### ESLint
|
1. _Redux_ for state management
|
||||||
|
2. _socket.io_ for transferring data in real-time
|
||||||
|
3. _React Router_ for routing
|
||||||
|
4. React DnD for drag-and-drop
|
||||||
|
5. @tanstack/react-table for all tables
|
||||||
|
|
||||||
[ESLint](https://eslint.org/) is a pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript and Typescript.
|
|
||||||
|
|
||||||
[.eslintrc.json file](<(https://eslint.org/docs/user-guide/configuring)>) (alternatively configurations can be written in Javascript or YAML as well) is used describe the configurations required for ESLint. Below is the .eslintrc.json file which has been used.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
"extends": ["airbnb"],
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-console": "off",
|
|
||||||
"comma-dangle": "off",
|
|
||||||
"react/jsx-filename-extension": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
[Airbnb's Javascript Style Guide](https://github.com/airbnb/javascript) which has been used by the majority of JavaScript and Typescript developers worldwide. Since the aim is support for both client (browser) and server side (Node.js) source code, the **env** has been set to browser and node.
|
|
||||||
Optionally, you can override the current settings by installing `eslint` globally and running `eslint --init` to change the configurations to suit your needs. [**no-console**](https://eslint.org/docs/rules/no-console), [**comma-dangle**](https://eslint.org/docs/rules/comma-dangle) and [**react/jsx-filename-extension**](https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md) rules have been turned off.
|
|
||||||
|
|
||||||
### Webpack
|
|
||||||
|
|
||||||
[Webpack](https://webpack.js.org/) is a module bundler. Its main purpose is to capable Front-end developers to experience a modular programming style and bundle JavaScript and CSS files for usage in a browser.
|
|
||||||
|
|
||||||
[webpack.config.js](https://webpack.js.org/configuration/) file has been used to describe the configurations required for webpack. Below is the webpack.config.js file which has been used.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const path = require('path');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
||||||
const CopyPlugin = require('copy-webpack-plugin');
|
|
||||||
|
|
||||||
const outputDirectory = 'dist';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
entry: ['babel-polyfill', './src/client/index.tsx'],
|
|
||||||
output: {
|
|
||||||
path: path.join(__dirname, outputDirectory),
|
|
||||||
filename: './js/[name].bundle.js'
|
|
||||||
},
|
|
||||||
devtool: "source-map",
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.(js|jsx)$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: {
|
|
||||||
loader: 'babel-loader'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.tsx?$/,
|
|
||||||
use:[
|
|
||||||
{
|
|
||||||
loader: "awesome-typescript-loader"
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exclude: /node_modules/
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enforce: "pre",
|
|
||||||
test: /\.js$/,
|
|
||||||
loader: "source-map-loader"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.less$/,
|
|
||||||
use: [
|
|
||||||
{ loader: 'style-loader' },
|
|
||||||
{
|
|
||||||
loader: MiniCssExtractPlugin.loader,
|
|
||||||
options: {
|
|
||||||
publicPath: './Less',
|
|
||||||
hmr: process.env.NODE_ENV === 'development',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ loader: 'css-loader' },
|
|
||||||
{
|
|
||||||
loader: 'less-loader',
|
|
||||||
options: {
|
|
||||||
strictMath: true,
|
|
||||||
noIeCompat: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(png|woff|woff2|eot|ttf|svg)$/,
|
|
||||||
loader: 'url-loader?limit=100000'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: ['*', '.ts', '.tsx', '.js', '.jsx', '.json', '.less']
|
|
||||||
},
|
|
||||||
devServer: {
|
|
||||||
port: 3000,
|
|
||||||
open: true,
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8050'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new CleanWebpackPlugin([outputDirectory]),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
template: './public/index.html',
|
|
||||||
favicon: './public/favicon.ico',
|
|
||||||
title: "Book Manager",
|
|
||||||
}),
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
filename: './css/[name].css',
|
|
||||||
chunkFilename: './css/[id].css',
|
|
||||||
}),
|
|
||||||
new CopyPlugin([
|
|
||||||
{ from: './src/client/Assets', to: 'assets' },
|
|
||||||
])
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
1. **entry:** entry: ./src/client/index.tsx is where the application starts executing and Webpack starts bundling.
|
|
||||||
Note: babel-polyfill is added to support async/await. Read more [here](https://babeljs.io/docs/en/babel-polyfill#usage-in-node-browserify-webpack).
|
|
||||||
2. **output path and filename:** the target directory and the filename for the bundled output.
|
|
||||||
3. **module loaders:** Module loaders are transformations that are applied on the source code of a module. We pass all the js file through [babel-loader](https://github.com/babel/babel-loader) to transform JSX to Javascript. CSS files are passed through [css-loaders](https://github.com/webpack-contrib/css-loader) and [style-loaders](https://github.com/webpack-contrib/style-loader) to load and bundle CSS files. Fonts and images are loaded through url-loader.
|
|
||||||
4. **Dev Server:** Configurations for the webpack-dev-server which will be described in coming section.
|
|
||||||
5. **plugins:** [clean-webpack-plugin](https://github.com/johnagan/clean-webpack-plugin) is a webpack plugin to remove the build directory before building. [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin) simplifies creation of HTML files to serve your webpack bundles. It loads the template (public/index.html) and injects the output bundle.
|
|
||||||
|
|
||||||
### Webpack dev server
|
|
||||||
|
|
||||||
[Webpack dev server](https://webpack.js.org/configuration/dev-server/) is used along with webpack. It provides a development server that enables live reloading for the client side code changes.
|
|
||||||
|
|
||||||
The devServer section of webpack.config.js contains the configuration required to run webpack-dev-server which is given below.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
devServer: {
|
|
||||||
port: 3000,
|
|
||||||
open: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://localhost:8050"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
[**Port**](https://webpack.js.org/configuration/dev-server/#devserver-port) specifies the Webpack dev server to listen on this particular port (3000 in this case). When [**open**](https://webpack.js.org/configuration/dev-server/#devserver-open) is set to true, it will automatically open the home page on start-up. [Proxying](https://webpack.js.org/configuration/dev-server/#devserver-proxy) URLs can be useful when you have a separate API backend development server, and you want to send API requests on the same domain.
|
|
||||||
|
|||||||
255
src/client/actions/airdcpp.actions.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import {
|
||||||
|
SearchQuery,
|
||||||
|
SearchInstance,
|
||||||
|
PriorityEnum,
|
||||||
|
SearchResponse,
|
||||||
|
} from "threetwo-ui-typings";
|
||||||
|
import {
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
SEARCH_SERVICE_BASE_URI,
|
||||||
|
} from "../constants/endpoints";
|
||||||
|
import {
|
||||||
|
AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||||
|
AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||||
|
AIRDCPP_HUB_SEARCHES_SENT,
|
||||||
|
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||||
|
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||||
|
AIRDCPP_BUNDLES_FETCHED,
|
||||||
|
AIRDCPP_SEARCH_IN_PROGRESS,
|
||||||
|
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
|
||||||
|
LS_SINGLE_IMPORT,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
AIRDCPP_TRANSFERS_FETCHED,
|
||||||
|
LIBRARY_ISSUE_BUNDLES,
|
||||||
|
AIRDCPP_SOCKET_CONNECTED,
|
||||||
|
AIRDCPP_SOCKET_DISCONNECTED,
|
||||||
|
} from "../constants/action-types";
|
||||||
|
import { isNil } from "lodash";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface SearchData {
|
||||||
|
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
|
||||||
|
hub_urls: string[] | undefined | null;
|
||||||
|
priority: PriorityEnum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<NodeJS.Timeout> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleAirDCPPSocketConnectionStatus =
|
||||||
|
(status: String, payload?: any) => async (dispatch) => {
|
||||||
|
switch (status) {
|
||||||
|
case "connected":
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_SOCKET_CONNECTED,
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disconnected":
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_SOCKET_DISCONNECTED,
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log("Can't set AirDC++ socket status.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const search =
|
||||||
|
(data: SearchData, ADCPPSocket: any, credentials: any) =>
|
||||||
|
async (dispatch) => {
|
||||||
|
try {
|
||||||
|
if (!ADCPPSocket.isConnected()) {
|
||||||
|
await ADCPPSocket();
|
||||||
|
}
|
||||||
|
const instance: SearchInstance = await ADCPPSocket.post("search");
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_SEARCH_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We want to get notified about every new result in order to make the user experience better
|
||||||
|
await ADCPPSocket.addListener(
|
||||||
|
`search`,
|
||||||
|
"search_result_added",
|
||||||
|
async (groupedResult) => {
|
||||||
|
// ...add the received result in the UI
|
||||||
|
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||||
|
groupedResult,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
|
||||||
|
await ADCPPSocket.addListener(
|
||||||
|
`search`,
|
||||||
|
"search_result_updated",
|
||||||
|
async (groupedResult) => {
|
||||||
|
// ...update properties of the existing result in the UI
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||||
|
groupedResult,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
|
||||||
|
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
|
||||||
|
await ADCPPSocket.addListener(
|
||||||
|
`search`,
|
||||||
|
"search_hub_searches_sent",
|
||||||
|
async (searchInfo) => {
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
// Check the number of received results (in real use cases we should know that even without calling the API)
|
||||||
|
const currentInstance = await ADCPPSocket.get(
|
||||||
|
`search/${instance.id}`,
|
||||||
|
);
|
||||||
|
if (currentInstance.result_count === 0) {
|
||||||
|
// ...nothing was received, show an informative message to the user
|
||||||
|
console.log("No more search results.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The search can now be considered to be "complete"
|
||||||
|
// If there's an "in progress" indicator in the UI, that could also be disabled here
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_HUB_SEARCHES_SENT,
|
||||||
|
searchInfo,
|
||||||
|
instance,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
|
// Finally, perform the actual search
|
||||||
|
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadAirDCPPItem =
|
||||||
|
(
|
||||||
|
searchInstanceId: Number,
|
||||||
|
resultId: String,
|
||||||
|
comicObjectId: String,
|
||||||
|
name: String,
|
||||||
|
size: Number,
|
||||||
|
type: any,
|
||||||
|
ADCPPSocket: any,
|
||||||
|
credentials: any,
|
||||||
|
): void =>
|
||||||
|
async (dispatch) => {
|
||||||
|
try {
|
||||||
|
if (!ADCPPSocket.isConnected()) {
|
||||||
|
await ADCPPSocket.connect();
|
||||||
|
}
|
||||||
|
let bundleDBImportResult = {};
|
||||||
|
const downloadResult = await ADCPPSocket.post(
|
||||||
|
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isNil(downloadResult)) {
|
||||||
|
bundleDBImportResult = await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/applyAirDCPPDownloadMetadata`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
bundleId: downloadResult.bundle_info.id,
|
||||||
|
comicObjectId,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||||
|
downloadResult,
|
||||||
|
bundleDBImportResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
comicBookDetail: bundleDBImportResult.data,
|
||||||
|
IMS_inProgress: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBundlesForComic =
|
||||||
|
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
|
||||||
|
async (dispatch) => {
|
||||||
|
try {
|
||||||
|
if (!ADCPPSocket.isConnected()) {
|
||||||
|
await ADCPPSocket.connect();
|
||||||
|
}
|
||||||
|
const comicObject = await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
id: `${comicObjectId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// get only the bundles applicable for the comic
|
||||||
|
if (comicObject.data.acquisition.directconnect) {
|
||||||
|
const filteredBundles =
|
||||||
|
comicObject.data.acquisition.directconnect.downloads.map(
|
||||||
|
async ({ bundleId }) => {
|
||||||
|
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_BUNDLES_FETCHED,
|
||||||
|
bundles: await Promise.all(filteredBundles),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTransfers =
|
||||||
|
(ADCPPSocket: any, credentials: any) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
if (!ADCPPSocket.isConnected()) {
|
||||||
|
await ADCPPSocket.connect();
|
||||||
|
}
|
||||||
|
const bundles = await ADCPPSocket.get("queue/bundles/1/85", {});
|
||||||
|
if (!isNil(bundles)) {
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_TRANSFERS_FETCHED,
|
||||||
|
bundles,
|
||||||
|
});
|
||||||
|
const bundleIds = bundles.map((bundle) => bundle.id);
|
||||||
|
// get issues with matching bundleIds
|
||||||
|
const issue_bundles = await axios({
|
||||||
|
url: `${SEARCH_SERVICE_BASE_URI}/groupIssuesByBundles`,
|
||||||
|
method: "POST",
|
||||||
|
data: { bundleIds },
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: LIBRARY_ISSUE_BUNDLES,
|
||||||
|
issue_bundles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,18 +1,53 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import rateLimiter from "axios-rate-limit";
|
import rateLimiter from "axios-rate-limit";
|
||||||
|
import { setupCache } from "axios-cache-interceptor";
|
||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
import {
|
import {
|
||||||
CV_SEARCH_SUCCESS,
|
CV_SEARCH_SUCCESS,
|
||||||
CV_API_CALL_IN_PROGRESS,
|
CV_API_CALL_IN_PROGRESS,
|
||||||
CV_API_GENERIC_FAILURE,
|
CV_API_GENERIC_FAILURE,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
|
CV_CLEANUP,
|
||||||
|
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||||
|
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||||
|
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||||
|
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
|
CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
|
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||||
|
LIBRARY_STATISTICS_FETCHED,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
import { COMICBOOKINFO_SERVICE_URI } from "../constants/endpoints";
|
import {
|
||||||
|
COMICVINE_SERVICE_URI,
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
} from "../constants/endpoints";
|
||||||
|
|
||||||
const http = rateLimiter(axios.create(), {
|
const http = rateLimiter(axios.create(), {
|
||||||
maxRequests: 1,
|
maxRequests: 1,
|
||||||
perMilliseconds: 1000,
|
perMilliseconds: 1000,
|
||||||
maxRPS: 1,
|
maxRPS: 1,
|
||||||
});
|
});
|
||||||
|
const cachedAxios = setupCache(axios);
|
||||||
|
export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
dispatch({
|
||||||
|
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||||
|
method: "get",
|
||||||
|
params: options,
|
||||||
|
}).then((response) => {
|
||||||
|
dispatch({
|
||||||
|
type: CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
|
data: response.data.result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const comicinfoAPICall = (options) => async (dispatch) => {
|
export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,7 +55,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
type: CV_API_CALL_IN_PROGRESS,
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
inProgress: true,
|
inProgress: true,
|
||||||
});
|
});
|
||||||
const serviceURI = COMICBOOKINFO_SERVICE_URI + options.callURIAction;
|
const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`;
|
||||||
const response = await http(serviceURI, {
|
const response = await http(serviceURI, {
|
||||||
method: options.callMethod,
|
method: options.callMethod,
|
||||||
params: options.callParams,
|
params: options.callParams,
|
||||||
@@ -29,16 +64,13 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
paramsSerializer: (params) => {
|
|
||||||
return qs.stringify(params, { arrayFormat: "repeat" });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (options.callURIAction) {
|
switch (options.callURIAction) {
|
||||||
case "search":
|
case "search":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_SEARCH_SUCCESS,
|
type: CV_SEARCH_SUCCESS,
|
||||||
result: response.data,
|
searchResults: response.data,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -53,3 +85,127 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
export const getIssuesForSeries =
|
||||||
|
(comicObjectID: string) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: CV_CLEANUP,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issues = await axios({
|
||||||
|
url: `${COMICVINE_SERVICE_URI}/getIssuesForSeries`,
|
||||||
|
method: "POST",
|
||||||
|
params: {
|
||||||
|
comicObjectID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(issues);
|
||||||
|
dispatch({
|
||||||
|
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||||
|
issues: issues.data.results,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const analyzeLibrary = (issues) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
const queryObjects = issues.map((issue) => {
|
||||||
|
const { id, name, issue_number } = issue;
|
||||||
|
return {
|
||||||
|
issueId: id,
|
||||||
|
issueName: name,
|
||||||
|
volumeName: issue.volume.name,
|
||||||
|
issueNumber: issue_number,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const foo = await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/findIssueForSeries`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
queryObjects,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||||
|
matches: foo.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLibraryStatistics = () => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
const result = await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: LIBRARY_STATISTICS_FETCHED,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComicBookDetailById =
|
||||||
|
(comicBookObjectId: string) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
|
IMS_inProgress: true,
|
||||||
|
});
|
||||||
|
const result = await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
id: comicBookObjectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
comicBookDetail: result.data,
|
||||||
|
IMS_inProgress: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComicBooksDetailsByIds =
|
||||||
|
(comicBookObjectIds: Array<string>) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
|
IMS_inProgress: true,
|
||||||
|
});
|
||||||
|
const result = await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooksByIds`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
ids: comicBookObjectIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||||
|
comicBooks: result.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyComicVineMatch =
|
||||||
|
(match, comicObjectId) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
|
IMS_inProgress: true,
|
||||||
|
});
|
||||||
|
const result = await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
match,
|
||||||
|
comicObjectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
comicBookDetail: result.data,
|
||||||
|
IMS_inProgress: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,22 +1,48 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { IFolderData, IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
import { IFolderData } from "threetwo-ui-typings";
|
||||||
import { API_BASE_URI, SOCKET_BASE_URI } from "../constants/endpoints";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import {
|
import {
|
||||||
IMS_COMICBOOK_METADATA_FETCHED,
|
COMICVINE_SERVICE_URI,
|
||||||
IMS_SOCKET_CONNECTION_CONNECTED,
|
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
LIBRARY_SERVICE_HOST,
|
||||||
|
SEARCH_SERVICE_BASE_URI,
|
||||||
|
} from "../constants/endpoints";
|
||||||
|
import {
|
||||||
|
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||||
|
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||||
|
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
||||||
IMS_RECENT_COMICS_FETCHED,
|
IMS_RECENT_COMICS_FETCHED,
|
||||||
|
IMS_WANTED_COMICS_FETCHED,
|
||||||
CV_API_CALL_IN_PROGRESS,
|
CV_API_CALL_IN_PROGRESS,
|
||||||
CV_SEARCH_SUCCESS,
|
CV_SEARCH_SUCCESS,
|
||||||
CV_CLEANUP,
|
CV_CLEANUP,
|
||||||
|
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||||
|
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||||
|
IMS_CV_METADATA_IMPORT_FAILED,
|
||||||
|
LS_IMPORT,
|
||||||
|
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||||
|
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
|
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
|
||||||
|
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
|
SS_SEARCH_RESULTS_FETCHED,
|
||||||
|
SS_SEARCH_IN_PROGRESS,
|
||||||
|
FILEOPS_STATE_RESET,
|
||||||
|
LS_IMPORT_CALL_IN_PROGRESS,
|
||||||
|
LS_TOGGLE_IMPORT_QUEUE,
|
||||||
|
SS_SEARCH_FAILED,
|
||||||
|
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||||
|
WANTED_COMICS_FETCHED,
|
||||||
|
VOLUMES_FETCHED,
|
||||||
|
CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
import { refineQuery } from "../shared/utils/filenameparser.utils";
|
import { success } from "react-notification-system-redux";
|
||||||
import { matchScorer } from "../shared/utils/searchmatchscorer.utils";
|
|
||||||
|
import { isNil, map } from "lodash";
|
||||||
|
|
||||||
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||||
return axios
|
return axios
|
||||||
.request<Array<IFolderData>>({
|
.request<Array<IFolderData>>({
|
||||||
url: "http://localhost:3000/api/import/walkFolders",
|
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: {
|
data: {
|
||||||
basePathToWalk: path,
|
basePathToWalk: path,
|
||||||
@@ -31,131 +57,296 @@ export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Fetches comic book covers along with some metadata
|
* Fetches comic book covers along with some metadata
|
||||||
*
|
* @return the comic book metadata
|
||||||
* using {@link Renderer}.
|
|
||||||
*
|
|
||||||
* Used by external plugins
|
|
||||||
*
|
|
||||||
* @param {Object} options
|
|
||||||
* @return {Promise<string>} HTML of the page
|
|
||||||
*/
|
*/
|
||||||
export const fetchComicBookMetadata = (options) => async (dispatch) => {
|
export const fetchComicBookMetadata = () => async (dispatch) => {
|
||||||
const extractionOptions = {
|
dispatch({
|
||||||
sourceFolder: options,
|
type: LS_IMPORT_CALL_IN_PROGRESS,
|
||||||
extractTarget: "cover",
|
|
||||||
targetExtractionFolder: "./userdata/covers",
|
|
||||||
extractionMode: "bulk",
|
|
||||||
paginationOptions: {
|
|
||||||
pageLimit: 25,
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const walkedFolders = await walkFolder("./comics");
|
|
||||||
|
|
||||||
const socket = io(SOCKET_BASE_URI, {
|
|
||||||
reconnectionDelayMax: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("connect", () => {
|
// dispatch(
|
||||||
console.log(`connect ${socket.id}`);
|
// success({
|
||||||
dispatch({
|
// // uid: 'once-please', // you can specify your own uid if required
|
||||||
type: IMS_SOCKET_CONNECTION_CONNECTED,
|
// title: "Import Started",
|
||||||
socketConnected: true,
|
// message: `<span class="icon-text has-text-success"><i class="fas fa-plug"></i></span> Socket <span class="has-text-info">${socket.id}</span> connected. <strong>${walkedFolders.length}</strong> comics scanned.`,
|
||||||
});
|
// dismissible: "click",
|
||||||
});
|
// position: "tr",
|
||||||
|
// autoDismiss: 0,
|
||||||
socket.on("disconnect", () => {
|
// }),
|
||||||
console.log(`disconnect`);
|
// );
|
||||||
});
|
dispatch({
|
||||||
socket.emit("importComicsInDB", {
|
type: LS_IMPORT,
|
||||||
action: "getComicCovers",
|
meta: { remote: true },
|
||||||
params: {
|
data: {},
|
||||||
extractionOptions,
|
|
||||||
walkedFolders,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("comicBookCoverMetadata", (data: IExtractedComicBookCoverFile) => {
|
|
||||||
dispatch({
|
|
||||||
type: IMS_COMICBOOK_METADATA_FETCHED,
|
|
||||||
data,
|
|
||||||
dataTransferred: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const toggleImportQueueStatus = (options) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: LS_TOGGLE_IMPORT_QUEUE,
|
||||||
|
meta: { remote: true },
|
||||||
|
data: { manjhul: "jigyadam", action: options.action },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Fetches comic book metadata for various types
|
||||||
|
* @return metadata for the comic book object categories
|
||||||
|
* @param options
|
||||||
|
**/
|
||||||
|
export const getComicBooks = (options) => async (dispatch) => {
|
||||||
|
const { paginationOptions, predicate, comicStatus } = options;
|
||||||
|
|
||||||
export const getRecentlyImportedComicBooks = (options) => async (dispatch) => {
|
const response = await axios.request({
|
||||||
const { paginationOptions } = options;
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||||
return axios
|
method: "POST",
|
||||||
.request({
|
data: {
|
||||||
url: "http://localhost:3000/api/import/getRecentlyImportedComicBooks",
|
paginationOptions,
|
||||||
method: "POST",
|
predicate,
|
||||||
data: {
|
},
|
||||||
paginationOptions,
|
});
|
||||||
},
|
|
||||||
})
|
switch (comicStatus) {
|
||||||
.then((response) => {
|
case "recent":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_RECENT_COMICS_FETCHED,
|
type: IMS_RECENT_COMICS_FETCHED,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
});
|
});
|
||||||
});
|
break;
|
||||||
|
case "wanted":
|
||||||
|
dispatch({
|
||||||
|
type: IMS_WANTED_COMICS_FETCHED,
|
||||||
|
data: response.data.docs,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("Unrecognized comic status.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchComicVineMatches = (searchPayload) => (dispatch) => {
|
/**
|
||||||
try {
|
* Makes a call to library service to import the comic book metadata into the ThreeTwo data store.
|
||||||
const issueString = searchPayload.rawFileDetails.path.split("/").pop();
|
* @returns Nothing.
|
||||||
let seriesSearchQuery = {};
|
* @param payload
|
||||||
const issueSearchQuery = refineQuery(issueString);
|
*/
|
||||||
if (searchPayload.rawFileDetails.containedIn !== "comics") {
|
export const importToDB =
|
||||||
seriesSearchQuery = refineQuery(
|
(sourceName: string, metadata?: any) => (dispatch) => {
|
||||||
searchPayload.rawFileDetails.containedIn.split("/").pop(),
|
try {
|
||||||
);
|
const comicBookMetadata = {
|
||||||
}
|
importType: "new",
|
||||||
|
payload: {
|
||||||
dispatch({
|
rawFileDetails: {
|
||||||
type: CV_API_CALL_IN_PROGRESS,
|
name: "",
|
||||||
});
|
},
|
||||||
|
importStatus: {
|
||||||
axios
|
isImported: true,
|
||||||
.request({
|
tagged: false,
|
||||||
url: "http://localhost:3080/api/comicvine/fetchseries",
|
matchedResult: {
|
||||||
method: "POST",
|
score: "0",
|
||||||
data: {
|
},
|
||||||
format: "json",
|
},
|
||||||
sort: "name%3Aasc",
|
sourcedMetadata: metadata || null,
|
||||||
query: issueSearchQuery.searchParams.searchTerms.name,
|
acquisition: { source: { wanted: true, name: sourceName } },
|
||||||
fieldList: "id",
|
|
||||||
limit: "10",
|
|
||||||
offset: "0",
|
|
||||||
resources: "issue",
|
|
||||||
},
|
},
|
||||||
transformResponse: [
|
};
|
||||||
(r) => {
|
dispatch({
|
||||||
const searchMatches = JSON.parse(r);
|
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||||
return matchScorer(searchMatches.results, {
|
|
||||||
issue: issueSearchQuery,
|
|
||||||
series: seriesSearchQuery,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
dispatch({
|
|
||||||
type: CV_SEARCH_SUCCESS,
|
|
||||||
searchResults: response.data,
|
|
||||||
searchQueryObject: {
|
|
||||||
issue: issueSearchQuery,
|
|
||||||
series: seriesSearchQuery,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
return axios
|
||||||
|
.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||||
|
method: "POST",
|
||||||
|
data: comicBookMetadata,
|
||||||
|
// transformResponse: (r: string) => JSON.parse(r),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const { data } = response;
|
||||||
|
dispatch({
|
||||||
|
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||||
|
importResult: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_CV_METADATA_IMPORT_FAILED,
|
||||||
|
importError: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* return { issueSearchQuery, series: seriesSearchQuery.searchParams }; */
|
export const fetchVolumeGroups = () => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
const response = await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
dispatch({
|
|
||||||
type: CV_CLEANUP,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
export const fetchComicVineMatches =
|
||||||
|
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||||
|
console.log(issueSearchQuery);
|
||||||
|
try {
|
||||||
|
dispatch({
|
||||||
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
axios
|
||||||
|
.request({
|
||||||
|
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
format: "json",
|
||||||
|
// hack
|
||||||
|
query: issueSearchQuery.inferredIssueDetails.name
|
||||||
|
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||||
|
.trim(),
|
||||||
|
limit: "100",
|
||||||
|
page: 1,
|
||||||
|
resources: "volume",
|
||||||
|
scorerConfiguration: {
|
||||||
|
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||||
|
},
|
||||||
|
rawFileDetails: searchPayload.rawFileDetails,
|
||||||
|
},
|
||||||
|
transformResponse: (r) => {
|
||||||
|
const matches = JSON.parse(r);
|
||||||
|
return matches;
|
||||||
|
// return sortBy(matches, (match) => -match.score);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
let matches: any = [];
|
||||||
|
if (
|
||||||
|
!isNil(response.data.results) &&
|
||||||
|
response.data.results.length === 1
|
||||||
|
) {
|
||||||
|
matches = response.data.results;
|
||||||
|
} else {
|
||||||
|
matches = response.data.map((match) => match);
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: CV_SEARCH_SUCCESS,
|
||||||
|
searchResults: matches,
|
||||||
|
searchQueryObject: {
|
||||||
|
issue: issueSearchQuery,
|
||||||
|
series: seriesSearchQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: CV_CLEANUP,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is a proxy to `uncompressFullArchive` which uncompresses complete `rar` or `zip` archives
|
||||||
|
* @param {string} path The path to the compressed archive
|
||||||
|
* @param {any} options Options object
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export const extractComicArchive =
|
||||||
|
(path: string, options: any): any =>
|
||||||
|
async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
filePath: path,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Description
|
||||||
|
* @param {any} query
|
||||||
|
* @param {any} options
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export const searchIssue = (query, options) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||||
|
method: "POST",
|
||||||
|
data: { ...query, ...options },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.code === 404) {
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_FAILED,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (options.trigger) {
|
||||||
|
case "wantedComicsPage":
|
||||||
|
dispatch({
|
||||||
|
type: WANTED_COMICS_FETCHED,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "globalSearchBar":
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "libraryPage":
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_RESULTS_FETCHED,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "volumesPage":
|
||||||
|
dispatch({
|
||||||
|
type: VOLUMES_FETCHED,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const analyzeImage =
|
||||||
|
(imageFilePath: string | Buffer) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: FILEOPS_STATE_RESET,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const foo = await axios({
|
||||||
|
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
imageFilePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
|
result: foo.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
28
src/client/actions/metron.actions.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { isNil } from "lodash";
|
||||||
|
import { METRON_SERVICE_URI } from "../constants/endpoints";
|
||||||
|
|
||||||
|
export const fetchMetronResource = async (options) => {
|
||||||
|
const metronResourceResults = await axios.post(
|
||||||
|
`${METRON_SERVICE_URI}/fetchResource`,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
console.log(metronResourceResults);
|
||||||
|
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
||||||
|
const results = metronResourceResults.data.results.map((result) => {
|
||||||
|
return {
|
||||||
|
label: result.name || result.__str__,
|
||||||
|
value: result.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: results,
|
||||||
|
hasMore: !isNil(metronResourceResults.data.next),
|
||||||
|
additional: {
|
||||||
|
page: !isNil(metronResourceResults.data.next)
|
||||||
|
? options.query.page + 1
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
69
src/client/actions/settings.actions.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
SETTINGS_OBJECT_FETCHED,
|
||||||
|
SETTINGS_CALL_IN_PROGRESS,
|
||||||
|
SETTINGS_DB_FLUSH_SUCCESS,
|
||||||
|
} from "../constants/action-types";
|
||||||
|
import {
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
SETTINGS_SERVICE_BASE_URI,
|
||||||
|
} from "../constants/endpoints";
|
||||||
|
|
||||||
|
export const saveSettings =
|
||||||
|
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
|
||||||
|
const result = await axios({
|
||||||
|
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
|
||||||
|
method: "POST",
|
||||||
|
data: { settingsPayload, settingsObjectId },
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: SETTINGS_OBJECT_FETCHED,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSettings = (settingsKey?) => async (dispatch) => {
|
||||||
|
const result = await axios({
|
||||||
|
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
||||||
|
method: "POST",
|
||||||
|
data: settingsKey,
|
||||||
|
});
|
||||||
|
{
|
||||||
|
dispatch({
|
||||||
|
type: SETTINGS_OBJECT_FETCHED,
|
||||||
|
data: result.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSettings = () => async (dispatch) => {
|
||||||
|
const result = await axios({
|
||||||
|
url: `${SETTINGS_SERVICE_BASE_URI}/deleteSettings`,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data.ok === 1) {
|
||||||
|
dispatch({
|
||||||
|
type: SETTINGS_OBJECT_FETCHED,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const flushDb = () => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: SETTINGS_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flushDbResult = await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/flushDb`,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (flushDbResult) {
|
||||||
|
dispatch({
|
||||||
|
type: SETTINGS_DB_FLUSH_SUCCESS,
|
||||||
|
data: flushDbResult.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
112
src/client/assets/img/airdcpp_logo.svg
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="160px"
|
||||||
|
height="160px"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
version="1.1"
|
||||||
|
id="SVGRoot">
|
||||||
|
<defs
|
||||||
|
id="defs222">
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient5642">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#483e37;stop-opacity:1"
|
||||||
|
offset="0"
|
||||||
|
id="stop5638" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#339cc7;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop5640" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
xlink:href="#linearGradient5642"
|
||||||
|
id="linearGradient5658"
|
||||||
|
x1="13.811552"
|
||||||
|
y1="7.9124665"
|
||||||
|
x2="8.5506983"
|
||||||
|
y2="2.2998061"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="translate(0.47081933,0.18167275)" />
|
||||||
|
<linearGradient
|
||||||
|
xlink:href="#linearGradient5642"
|
||||||
|
id="linearGradient5658-6"
|
||||||
|
x1="13.811552"
|
||||||
|
y1="7.9124665"
|
||||||
|
x2="8.5506983"
|
||||||
|
y2="2.2998061"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="translate(-5.8345778,0.9966572)" />
|
||||||
|
<linearGradient
|
||||||
|
xlink:href="#linearGradient5642"
|
||||||
|
id="linearGradient5658-6-2"
|
||||||
|
x1="13.811552"
|
||||||
|
y1="7.9124665"
|
||||||
|
x2="8.5506983"
|
||||||
|
y2="2.2998061"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="translate(-2.2875303,6.2704208)" />
|
||||||
|
</defs>
|
||||||
|
<metadata
|
||||||
|
id="metadata225">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
id="layer1">
|
||||||
|
<circle
|
||||||
|
style="fill:#fd8635;fill-opacity:1;stroke-width:0.34364313;stroke:none;stroke-opacity:1"
|
||||||
|
id="path78-7"
|
||||||
|
cx="4.9123297"
|
||||||
|
cy="5.9923167"
|
||||||
|
r="4.75" />
|
||||||
|
<circle
|
||||||
|
style="opacity:0.61000001;fill:url(#linearGradient5658-6);fill-opacity:1;stroke-width:0.3436431"
|
||||||
|
id="path78-3-5-1"
|
||||||
|
cx="4.9046054"
|
||||||
|
cy="5.9923072"
|
||||||
|
r="4.75" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="layer2">
|
||||||
|
<circle
|
||||||
|
style="fill:#fd8635;fill-opacity:1;stroke-width:0.34364313"
|
||||||
|
id="path78"
|
||||||
|
cx="8.4543009"
|
||||||
|
cy="11.261043"
|
||||||
|
r="4.75" />
|
||||||
|
<circle
|
||||||
|
style="opacity:0.61000001;fill:url(#linearGradient5658-6-2);fill-opacity:1;stroke-width:0.3436431"
|
||||||
|
id="path78-3-5-1-9"
|
||||||
|
cx="8.4516525"
|
||||||
|
cy="11.266071"
|
||||||
|
r="4.75" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="layer3">
|
||||||
|
<circle
|
||||||
|
style="opacity:1;fill:#348fed;fill-opacity:1;stroke-width:0.3436431"
|
||||||
|
id="path78-3"
|
||||||
|
cx="11.211483"
|
||||||
|
cy="5.1752739"
|
||||||
|
r="4.75" />
|
||||||
|
<circle
|
||||||
|
style="opacity:0.61000001;fill:url(#linearGradient5658);fill-opacity:1;stroke-width:0.3436431"
|
||||||
|
id="path78-3-5"
|
||||||
|
cx="11.210003"
|
||||||
|
cy="5.1773238"
|
||||||
|
r="4.75" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
6
src/client/assets/img/comicinfoxml.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="384" height="512" viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<path id="Path" fill="#e1933c" fill-rule="evenodd" stroke="#393838" stroke-width="21" stroke-linecap="round" stroke-linejoin="round" d="M 215.786591 23.351563 C 215.786591 23.351563 98.801674 21.473022 81.803864 21.200073 C 21.811596 20.236725 23.297552 52.264709 22.462652 104.258026 C 21.073503 190.766876 22.587183 312.129272 22.115429 437.295349 C 21.974922 474.574432 57.495609 491.702881 94.239006 492.460449 C 172.222427 494.068359 287.278351 491.559753 287.278351 491.559753 C 287.278351 491.559753 361.530945 494.731964 361.601563 409.742523 C 361.689667 303.742554 361.407318 172.708862 361.407318 172.708862 L 215.786591 23.351563 L 215.786591 23.351563 Z"/>
|
||||||
|
<path id="Shape" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round" d="M 110 270 L 264 270 M 162 330 L 220 330"/>
|
||||||
|
<path id="Line" fill="none" stroke="#ffffff" stroke-width="33" stroke-linecap="round" stroke-linejoin="round" d="M 110 395 L 264 394.999084"/>
|
||||||
|
<path id="path1" fill="none" stroke="#393838" stroke-width="21" stroke-linecap="round" stroke-linejoin="round" d="M 207 23 L 208 175 L 349 175"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
5
src/client/assets/img/cvlogo.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
7
src/client/assets/img/locglogo.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated by Pixelmator Pro 2.4.3 -->
|
||||||
|
<svg width="624" height="561" viewBox="0 0 624 561" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<text id="LCG" xml:space="preserve"><tspan x="57" y="282" font-family="DIN Alternate" font-size="288" font-weight="700" fill="#ff4300" letter-spacing="-2.88" xml:space="preserve">L</tspan><tspan font-family="DIN Alternate" font-size="288" font-weight="700" fill="#ff4300" letter-spacing="11.52" xml:space="preserve">CG</tspan></text>
|
||||||
|
<path id="Rounded-Rectangle" fill="#ff4300" fill-rule="evenodd" stroke="none" d="M 96 322 C 84.954399 322 76 330.954407 76 342 L 76 346 C 76 357.045593 84.954399 366 96 366 L 193 366 C 204.045593 366 213 357.045593 213 346 L 213 342 C 213 330.954407 204.045593 322 193 322 Z"/>
|
||||||
|
<path id="Rounded-Rectangle-copy-2" fill="#ff4300" fill-rule="evenodd" stroke="none" d="M 425 322 C 413.954407 322 405 330.954407 405 342 L 405 346 C 405 357.045593 413.954407 366 425 366 L 522 366 C 533.045593 366 542 357.045593 542 346 L 542 342 C 542 330.954407 533.045593 322 522 322 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
6
src/client/assets/img/missing-file-2.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="384" height="512" viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<path id="Path" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 212 23 L 78 23 C 78 23 20 33 20 107 C 20 193.52002 25 440 25 440 C 25 440 20 494 98 494 C 176 494 291 490 291 490 C 291 490 365.293945 491.979614 364 407 C 362.386169 301.012268 360 170 360 170 L 212 23 L 212 23 Z"/>
|
||||||
|
<path id="Ellipse" fill="none" stroke="#ed230d" stroke-width="37" stroke-linecap="round" stroke-linejoin="round" d="M 266 320 C 266 279.13092 232.86908 246 192 246 C 151.13092 246 118 279.13092 118 320 C 118 360.86908 151.13092 394 192 394 C 232.86908 394 266 360.86908 266 320 Z"/>
|
||||||
|
<path id="Ellipse-copy" fill="none" stroke="#ed230d" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="0.1 16" stroke-dashoffset="0" d="M 223 320.5 C 223 303.103027 208.896973 289 191.5 289 C 174.103027 289 160 303.103027 160 320.5 C 160 337.896973 174.103027 352 191.5 352 C 208.896973 352 223 337.896973 223 320.5 Z"/>
|
||||||
|
<path id="path1" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 207 23 L 208 175 L 349 175"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
5
src/client/assets/img/missing-file.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="384" height="512" viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<path id="Path" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="56 56" stroke-dashoffset="56" d="M 221 23 L 78 23 C 78 23 20 33 20 107 C 20 193.52002 25 440 25 440 C 25 440 20 494 98 494 C 176 494 291 490 291 490 C 291 490 365.293945 491.979614 364 407 C 362.386169 301.012268 360 170 360 170 L 212 23 L 221 23 Z"/>
|
||||||
|
<path id="Ellipse" fill="none" stroke="#ed230d" stroke-width="25" stroke-linecap="round" stroke-linejoin="round" d="M 266 340 C 266 299.13092 232.86908 266 192 266 C 151.13092 266 118 299.13092 118 340 C 118 380.86908 151.13092 414 192 414 C 232.86908 414 266 380.86908 266 340 Z"/>
|
||||||
|
<path id="path1" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="56 56" stroke-dashoffset="56" d="M 181 3 L 181 173 L 269 174 L 305 175"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1016 B |
18
src/client/assets/img/noimage.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="104px" height="160px" viewBox="0 0 104 160" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
|
||||||
|
<!-- Generated by Pixelmator Pro 2.1.2 -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="41.41" y1="30.667" x2="78.01" y2="133.767">
|
||||||
|
<stop offset="0" stop-color="#ffecd2" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#fcb69f" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path id="Path" d="M12.093 0.081 C5.414 0.081 0 5.465 0 12.105 L0 147.976 C0 154.617 5.414 160 12.093 160 L91.907 160 C98.586 160 104 154.617 104 147.976 L104 12.105 C104 5.465 98.586 0.081 91.907 0.081 Z" fill-opacity="1" fill="url(#linear-gradient)" stroke="none"/>
|
||||||
|
<defs>
|
||||||
|
<text id="string" transform="matrix(1.0 0.0 0.0 1.0 20.0 54.0)">
|
||||||
|
<tspan fill="#252422" y="18.0" x="17.492" font-size="18" font-family="Avenir-Oblique, Avenir" text-decoration="none">NO </tspan>
|
||||||
|
<tspan fill="#252422" y="43.0" x="1.823000000000004" font-size="18" font-family="Avenir-Oblique, Avenir" text-decoration="none">COVER</tspan>
|
||||||
|
</text>
|
||||||
|
</defs>
|
||||||
|
<use id="NO-COVER" xlink:href="#string"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/client/assets/img/threetwo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
8
src/client/assets/img/threetwo.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="112px" height="28px" viewBox="0 0 112 28" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
|
||||||
|
<!-- Generated by Pixelmator Pro 2.2 -->
|
||||||
|
<defs>
|
||||||
|
<image id="image" width="45px" height="28px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC0AAAAcCAYAAAD1PDaSAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAALaADAAQAAAABAAAAHAAAAADa6ZRmAAALuElEQVRYCZVXCZRUxRWt5f9ep3u6Z9+gGRxwkEVcgIAKQURWAQFBRYMLGghqDKInajwuxwVQlAAalyNLICGI0QCiBkUQhIOETRlZRGBgNmZfenr/v37um9AcJAzqO+d2Vb+qX3Xr1atXrzi7uMgzzWY73WzQa0AEsNrp056avqPvaQ4OKCAOJICLjkWd25M8NMwBaOCXgG+Ac+Uy/HkW8AJvAh8CFxMdjT6gH3ClEKzQqUnH8GJXdmVQtTa2GrGKYCISjKqtaF8FhIALSnukaYK/2YQeTBHO5gajpTv+3wo0nhnFgfJ94KTfpZc0hhPUNh6oB84XWvQQwcTYPEemx6NcCU9W9YkFE7P75HhEEWdc55y3wrgimrCsQQvLd1WHYjF88wgQPX8w+i8upITOqXGZWlL4YU5Jp/evcUsHuQeRSgqR1vbN6lSzd3ZgqschqX1ysvGcsgBkX52eNn7WtsCyjJ0dVnbcNHqqsWZa7m15qeJSELYsi9dwodxCWE6XnbOvZ3Xwee2C3GPMOeP8qNoeae6QTsvN7JcIJr193D1O4qvrgWR/4bJJ7kvRbsFkmeMv91agbRRAO5SUgToTL7ySPcv2dNqMjp31vN665CrabXV2XciMD3mjYkf3Ocd3jllSXnao0ggxxVuxAieXVu+nbswko/RMDnR+mSRxvp45hc4twXSLWbYpKaPpcGQCtNUkpuAK5uBtB3T6AE8DdOTb1IeklxTyvqUdnz90m2fYYJwzOmyxFU2fbOnx8vd1feeXfnmsNvpWOKFmllRG5o57t+xrw+KfoU8bn7/uagqiXglcUOgEX0hU2IwoSynwknygo3fyhKegM/kZLUJG40p3wyZZKbaeTl0akYR5E/TLgfu3Fy7xBnj+NPiqVWXWbxhT+YCrMlabgxDxBtr3AQaQLwV7qCBdfi6FVYQYYjVGrG3f1cZo8a8AF5SkpdPQSj6UcaZXPGzG4hVmTRn9T+HuK2xcI6Ijk+1Rg0UOnI62covVaILl+t0Sh4l1Aa6f4h+ZCcI9qO+S1nVvX3licmp5rHYTCD8E1X8AIlykS/HSxumdem76XafH4d5TLMWDAxaUJpRiX6G9XUsTabLe4rxc+8w0v7YC9SuAONxiz6iKB8u5YCGN8bRh3gE0yHCArG4YprVv6t9P12OCJljTlumSbugbdCZzHvPfTeGSHTFL9z55elEvVBcBqwGKChSxrgWe+fCe/JyiTJnKLGVHZObT19RsDscUxfylQLuxmkhTvE1ZuTBPLpmfW2yzsT/hvwdY0pBoTouo+GnFLflc2swAdOmAHSDZmCK5H3N1trgFItwJXZUmtOJUkeIJq5g+sfxRujAoxu8BiAS5492Y9IHFk3IP9syzXUIuga9Pzt3UtOXTQy1kwKcBWly7QoPUp3pt2CkV8PuFVZDvDB8/EZkK/VuwdmhtcEvTZM/Qwgzhz3ZKe03EjNEFEQaOJDAfLjKFX5AmTqwhroxvHqqdV7AjvJfVJZrnQneQGiAUER7ukePovequPLfXLqdwzkyci3d6zDvhjxuKDPJHADt3cSFLV4TCiVhrxJIC8Wve45n50A0AaBt3fGscLUKVa5w7M0QaWY5chCSGDogeoi2ChBKKypDJzI3rmjcLEH4b/5O3KBF+wmUXndben9fFa+eXMa7slU3G211fPFYAwtT+OJC8vFBtX4h01DCsT+csajyplOIZGSLg80hyAboF120N7TEQP1HlYrJvaDMqNwDk15ZpwcxYEOptNkdxEqgFaKe+BEjIbebpgqXsebhzpSa4j8wRiqiyAQtP0PmpBp4AGoCfJUSaZM2O3a3xeNzSpOTsrtt9ZLV7gfrSaFnU5GYLdbpc77YLBUUYIsIMkMYhgssLqyFixqFKTkw+SSslCz7jtWtG1SSb3yfiV+G2rjtVnzje57VTtVjxerSTC13wuob+gpIk3WCa1rdrN4YqyNo3DXFfAvJ5+MJugFWN2VBPXw9KufJ6FGRZOrw8GjdVXLG96GLSeYKO3CcpdDvOtgvhOzlR9LNr5kD7yYpTRS8eWzro9VP1obj5Z7SvBtrcK/nRzymTpGnCFStWN4UQCJSuc29qikaWGwyULWtZ13Y4dCW7FtiyqD4BkBKuYZdWocUF1WksGoeE6ve5pCiqmKzpGjdzDMW0sZv09ERCdYGFZ6N9O5Dsj+pPSpLr2VyCvjje3GqqkqPxDywh+MRxqaXQjQW2rGzagCjzv/HnZ82msNQTKPC5JVxUFKDODEVh76yMyLTzG46PlwGnsK6LGNw594C9alt1dBN6kP+Wn+358yoUWWih4HHmrqcKhMJY5fy/NCBGK3b7aMfVOGKos5agCnM4bg116uPoTiRp6wfhNjSYaPMIhRuSKrSybria7ywZpwe8dit3Y5X22a8/ZrvnHAjPQNsS4KIxGO0XEjpDU4C23OasyaGgSTfU1SW6wGxcaDKQk2U/Ct1tOXqmXSCwwI01h2Vz6hwXN2MjnHZRSukl6uToBgqyxMznervqXbrpCxvaBzd/EXEdbDGegZ7yjV/iDuh+VogsGXAoac4lTf83h0JKxhMiRi6y8Lks2vKOIBaGy+bgsEnBWforWbPqkGs4N07v0JZfcDoIvG3RTpsQqTOK47/iTGvq+M/EcXxPZyAZr1H9RULzE+EHXS5+BK+dvqij+LE0IijXbfk6fJrU2VlixK2j/M9+1GFhORGGmdJx+YlJef377PvDpalOnVGiBftxPD+QETLmLvZrSGq5N5zgi8MJ8zrolgI/FSEo7pPL0f3gAnzAtcDzwKKbR/kd65YFCvv29hbjv7vNsVEhoVV1Br7fviva6caBLs6F0GdM877qXd5Sa33jDyMrpi2KW9LIEabbb8lYMi4zPLtEMBZXHw3WoozHzWm7rB3oO4TGA84XIpgKXH0GBVIKIitMUymPW+c2OzPvmJSmjxysZzptMkCpwrHSCM3XI0maLD4dbjDCKey7du8Ph5Typ9M+0GGsGbfMHyh5KmQxQQdC8Raf4GG8LuxNcOg2C6OfJTJcrDXNnoDL8NhXFUYv9KWMjUJnUmyoDAJuQZB0pqSnm9Iyj465gR+dMNI1xoioLb40OQzxM4s+oLSCStwdylTW6aagSelxapJ0d7wJx34ReOe7gJbftduJ0YnmFqvan8aywNuKesu44YlFZdBJN6Mbea8hDt8YMvr9gCcgMy0okLjIbM0bg/vTUpkmrU7oWw+Qa9AuUlrwMIg4rhgzYfvw2Y+Mx+umID+ys1e3lldT8EqSzCvvIOtBcF2pMLg6WiMqNP3R2g3VNfEcxPhqtH2VJG1DHhwrkh2Gwtr5v0kdU/Le+s2lv52amoVtoa1k1XesCua/fo+NaUhBYVxZ3rXZ6IepQAgRmud6dNv2Ey2whO0A2q+5yq9nfxKJ0fVM308Ebknx+Tb//qNPx+hCPoIhaDEaZ4kq7JbHMkQdMoLUMF5Dy99r2fje2mbl9khnOGxF4TKn0PcNgM6amSRdGrHiWphFj6cwV/Zjaff07L/p4/rpd3oUogieg8qK5h/2Mc0yLBOZGMcDNKYXCGU7okTUCxfCfIgfyDXCnuKl7uChgVdnavmfVMZOSSkXCF3PvPfdFWVZhZ2GCsYLkbDgdZKo3bnqH1s/f3Ox7vPK8mDQNOCOStNYNBJRdA52QHcYJS2cwulZSZKm7K3u9orHw2sLFrQ4mC09YBZyUwXrwDkLAdywbI26kd1SpVWl9oKzSVhT8B+G72fF/xpIpJFbJ3SXa+LyCQt7TPz4aX7IWdvkzajtP+mFl/NzLrs0LKQ2jIIMrFa1fu7zu49s28ojTU0OzLurqUltR9loIq9NJNren3RntCtJ0rSSF3dHDz4ZZbE1dmUf0Dc3sEOKg/fRSUDiBy9RVvWtK/fkvTYzF9HNAY84LQ5ft10Vr+sHO9vjBtsvOY+0Ot1py8fPU93Gs/4dgs0hjzfdi9wZFxOuJ7wn/734tS3716/bhWFXAa3ARQmi/f/kv5XnqvnoFJszAAAAAElFTkSuQmCC"/>
|
||||||
|
</defs>
|
||||||
|
<use id="Background-Layer" xlink:href="#image" x="41px" y="0px" width="45px" height="28px"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -1,10 +1,51 @@
|
|||||||
@import "../../../../node_modules/bulma/bulma.sass";
|
@import "/node_modules/bulma/bulma.sass";
|
||||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
$fa-font-path: "/node_modules/@fortawesome/fontawesome-free/webfonts";
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
@import "/node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
@import "/node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
|
||||||
|
@import "/node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
||||||
$bg-color: yellow;
|
$bg-color: yellow;
|
||||||
$border-color: red;
|
$border-color: red;
|
||||||
|
|
||||||
|
$volume-color: #fdecd1;
|
||||||
|
$issue-color: #f2f1f9;
|
||||||
|
$size-8: 0.9rem;
|
||||||
|
$size-9: 0.7rem;
|
||||||
|
$flexSize: 4em;
|
||||||
|
$boxSpacing: 1em;
|
||||||
|
$colorText: #404646;
|
||||||
|
|
||||||
|
.is-size-8 {
|
||||||
|
font-size: $size-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-size-9 {
|
||||||
|
font-size: $size-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-tag {
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fff6de;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #4a4a4a;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: $size-9;
|
||||||
|
height: 1.5em;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-left: 0.55em;
|
||||||
|
padding-right: 0.55em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// global style overrides
|
||||||
|
|
||||||
|
pre {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
.app {
|
.app {
|
||||||
font-family: helvetica, arial, sans-serif;
|
font-family: helvetica, arial, sans-serif;
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
@@ -14,6 +55,85 @@ $border-color: red;
|
|||||||
background-color: $bg-color;
|
background-color: $bg-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Navbar
|
||||||
|
.navbar {
|
||||||
|
border-bottom: 1px solid #f2f1f9;
|
||||||
|
.download-progress-meter {
|
||||||
|
margin-left: -300px;
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
.airdcpp-status {
|
||||||
|
min-width: 300px;
|
||||||
|
line-height: 1.7rem;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #454a59;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #454a59;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulsating-circle {
|
||||||
|
position: relative;
|
||||||
|
left: -120%;
|
||||||
|
top: 20%;
|
||||||
|
transform: translateX(-50%) translateY(-50%);
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 300%;
|
||||||
|
height: 300%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-left: -100%;
|
||||||
|
margin-top: -100%;
|
||||||
|
border-radius: 45px;
|
||||||
|
background-color: #01a4e9;
|
||||||
|
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: green;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -0.4s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.33);
|
||||||
|
}
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-item.is-mega {
|
.navbar-item.is-mega {
|
||||||
position: static;
|
position: static;
|
||||||
|
|
||||||
@@ -23,8 +143,88 @@ $border-color: red;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
|
||||||
|
// slick slider overrides
|
||||||
|
.slick-slider {
|
||||||
|
margin-left: -10px;
|
||||||
|
.slick-list {
|
||||||
|
padding: 0 0px 15px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.recent-comics-container {
|
||||||
|
display: -webkit-box; /* Not needed if autoprefixing */
|
||||||
|
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||||
|
display: flex;
|
||||||
|
margin-left: -22px; /* gutter size offset */
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
.recent-comics-column {
|
||||||
|
padding-left: 22px; /* gutter size */
|
||||||
|
background-clip: padding-box;
|
||||||
|
& > div {
|
||||||
|
/* change div to reference your elements you put in <Masonry> */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.volumes-container {
|
||||||
|
.stack {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow:
|
||||||
|
/* The top layer shadow */ 0 -1px 1px rgba(0, 0, 0, 0.15),
|
||||||
|
/* The second layer */ 0 -10px 0 -5px #eee,
|
||||||
|
/* The second layer shadow */ 0 -10px 1px -4px rgba(0, 0, 0, 0.15),
|
||||||
|
/* The third layer */ 0 -20px 0 -10px #eee,
|
||||||
|
/* The third layer shadow */ 0 -20px 1px -9px rgba(0, 0, 0, 0.15);
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
.stack-title {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin: -5px 0 0 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom-left-radius: 0.25rem;
|
||||||
|
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
|
||||||
|
border-bottom-right-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.volumes-grid {
|
||||||
|
display: -webkit-box; /* Not needed if autoprefixing */
|
||||||
|
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||||
|
display: flex;
|
||||||
|
margin-left: -30px; /* gutter size offset */
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.volumes-grid-column {
|
||||||
|
padding-left: 22px; /* gutter size */
|
||||||
|
background-clip: padding-box;
|
||||||
|
& > div {
|
||||||
|
/* change div to reference your elements you put in <Masonry> */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.min {
|
.min {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
margin: auto;
|
||||||
|
.tag__custom {
|
||||||
|
height: auto !important;
|
||||||
|
padding: 0.3rem;
|
||||||
|
white-space: unset !important;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #effaf5;
|
||||||
|
color: #257953;
|
||||||
|
}
|
||||||
.tags {
|
.tags {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
@@ -41,7 +241,18 @@ $border-color: red;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.generic-card {
|
.generic-card {
|
||||||
max-width: 200px;
|
display: inline-block;
|
||||||
|
background-color: #fff;
|
||||||
|
border-top-left-radius: 0.4rem;
|
||||||
|
border-top-right-radius: 0.4rem;
|
||||||
|
border-bottom-left-radius: 0.4rem;
|
||||||
|
border-bottom-right-radius: 0.4rem;
|
||||||
|
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
.green-border {
|
||||||
|
border: 1px dotted #168b64;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.truncate {
|
.truncate {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
@@ -49,70 +260,249 @@ $border-color: red;
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.partial-rounded-card-image {
|
||||||
img {
|
figure {
|
||||||
max-width: 200px;
|
display: flex;
|
||||||
|
img {
|
||||||
|
border-top-left-radius: 0.4rem;
|
||||||
|
border-top-right-radius: 0.4rem;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rounded-card-image {
|
||||||
|
figure {
|
||||||
|
display: flex;
|
||||||
|
img {
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card-content {
|
||||||
|
.card-title {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.custom-icon,
|
||||||
|
i {
|
||||||
|
margin: 4px 4px 4px 0;
|
||||||
|
}
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card-container {
|
.card-container {
|
||||||
display: grid;
|
// display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
// grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
column-gap: 0.5em;
|
// column-gap: 0.5em;
|
||||||
row-gap: 1.2em;
|
// row-gap: 1.2em;
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
max-width: 200px;
|
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
|
|
||||||
|
.partial-rounded-card-image {
|
||||||
|
img {
|
||||||
|
border-top-left-radius: 0.4rem;
|
||||||
|
border-top-right-radius: 0.4rem;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rounded-card-image {
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.is-horizontal {
|
.is-horizontal {
|
||||||
flex-direction: row;
|
// margin: $boxSpacing / 2;
|
||||||
|
border-radius: 1.5em;
|
||||||
|
height: $flexSize;
|
||||||
|
max-width: $flexSize * 3;
|
||||||
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-basis: 50ex;
|
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 1;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
align-self: center;
|
// leaving this here... for posterity
|
||||||
.image {
|
img.image {
|
||||||
max-width: 60px;
|
border-top-left-radius: 8px;
|
||||||
img {
|
border-bottom-left-radius: 8px;
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
border-top-left-radius: 0.25em;
|
height: 100%;
|
||||||
border-bottom-left-radius: 0.25em;
|
max-width: $flexSize * 1.3;
|
||||||
}
|
object-fit: cover;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
img.cropped-image {
|
||||||
|
width: 70px;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: 100% 0;
|
||||||
|
// flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card-content {
|
}
|
||||||
align-self: center;
|
.card-content {
|
||||||
flex: 1;
|
align-self: top;
|
||||||
padding-left: 1em;
|
flex: 1;
|
||||||
padding-top: 0;
|
padding-left: 0.7em;
|
||||||
padding-bottom: 0;
|
padding-top: 0.4em;
|
||||||
font-size: 0.8em;
|
padding-bottom: 0em;
|
||||||
ul {
|
|
||||||
li.status {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
width: 400px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.is-divider {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// raw file details
|
||||||
|
.raw-file-details {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: beige;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comic-viewer {
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
// comicvine metadata
|
||||||
|
.comicvine-metadata {
|
||||||
|
background-color: #f2f1f9;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-metadata {
|
||||||
|
background-color: #fbffee;
|
||||||
|
padding: 0.8em;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
.name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #4a4f50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comicInfo-metadata {
|
||||||
|
background-color: #f7ebdd;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comic Detail
|
||||||
|
.comic-detail {
|
||||||
|
dl {
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
.airdcpp-text {
|
||||||
|
margin: 0 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AirDC++ search results
|
||||||
|
.dupe-search-result {
|
||||||
|
background: lavender;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
.search {
|
||||||
|
.main-search-bar {
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #999;
|
||||||
|
border-radius: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Library
|
||||||
|
.header-area {
|
||||||
|
width: 100%;
|
||||||
|
padding: 25px 0 15px 0;
|
||||||
|
position: sticky;
|
||||||
|
z-index:9999;
|
||||||
|
background: #fffffc;
|
||||||
|
top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library {
|
||||||
|
.table-controls {
|
||||||
|
background: #fffffc;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 126px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
margin: 0;
|
||||||
|
background: #fffffc;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: separate;
|
||||||
|
width: 100%;
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 250px;
|
||||||
|
z-index: 1;
|
||||||
|
background: #fffffc;
|
||||||
|
min-height: 130px;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
border: 0 none;
|
||||||
|
.card {
|
||||||
|
margin: 8px 0 7px 0;
|
||||||
|
.name {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
padding: 10px 0 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comic Detail
|
||||||
|
.control-palette {
|
||||||
|
background-color: #fff6de;
|
||||||
|
display: inline-block;
|
||||||
|
i {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
// padding: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// airdcpp downloads tab
|
||||||
|
.tabs {
|
||||||
|
.download-icon-labels {
|
||||||
|
.downloads-count {
|
||||||
|
margin: 0 1em -1px 0.4em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.download-tab-name {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawer content padding override
|
||||||
|
.slide-pane__content {
|
||||||
|
padding: 24px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-pane__header {
|
||||||
|
margin-top: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.comic-vine-match-drawer {
|
.comic-vine-match-drawer {
|
||||||
// comic detail drawer
|
// comic detail drawer
|
||||||
|
|
||||||
.search-criteria-card {
|
.search-criteria-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
.card-content {
|
.card-content {
|
||||||
@@ -128,38 +518,106 @@ $border-color: red;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume detail
|
||||||
|
.volume-details {
|
||||||
|
.is-volume-related {
|
||||||
|
$tag-background-color: $volume-color;
|
||||||
|
}
|
||||||
|
.issues-container {
|
||||||
|
display: -webkit-box; /* Not needed if autoprefixing */
|
||||||
|
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||||
|
display: flex;
|
||||||
|
margin-left: -10px; /* gutter size offset */
|
||||||
|
width: auto;
|
||||||
|
.issues-column {
|
||||||
|
max-width: 102px;
|
||||||
|
margin: 10px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
& > div {
|
||||||
|
/* change div to reference your elements you put in <Masonry> */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Potential issue matches in library slideout panel
|
||||||
|
.potential-matches-container {
|
||||||
|
.potential-issue-match {
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: beige;
|
||||||
|
padding: 10px;
|
||||||
|
pre {
|
||||||
|
padding: 5px;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
white-space: pre-wrap; /* Since CSS 2.1 */
|
||||||
|
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||||
|
white-space: -pre-wrap; /* Opera 4-6 */
|
||||||
|
white-space: -o-pre-wrap; /* Opera 7 */
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.generic-card {
|
||||||
|
max-width: 90px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// comicvine search results
|
// comicvine search results
|
||||||
.search-results-container {
|
.search-results-container {
|
||||||
margin: 15px 0 0 0;
|
margin: 15px 0 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
tbody tr:nth-child(odd) {
|
|
||||||
background: #f6f6f6;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result {
|
.search-result {
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f2f1f9;
|
||||||
.cover-image {
|
.cover-image {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
.search-result-details {
|
.search-result-details {
|
||||||
width: 100%;
|
|
||||||
.score {
|
.score {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
margin-left: 10px;
|
}
|
||||||
|
.volume-information {
|
||||||
|
margin-top: -2.5em;
|
||||||
|
width: 80%;
|
||||||
|
background: #fdecd1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.vertical-line {
|
||||||
|
position: relative;
|
||||||
|
top: -25px;
|
||||||
|
left: 1.5rem;
|
||||||
|
border: 2px dotted #ccc;
|
||||||
|
width: 20px;
|
||||||
|
min-height: 35px;
|
||||||
|
|
||||||
|
border-color: transparent transparent #f3a22d #f3a22d;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Library grid
|
||||||
|
.my-masonry-grid {
|
||||||
|
display: -webkit-box; /* Not needed if autoprefixing */
|
||||||
|
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||||
|
display: flex;
|
||||||
|
margin-left: -30px; /* gutter size offset */
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.my-masonry-grid_column {
|
||||||
|
padding-left: 30px; /* gutter size */
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-masonry-grid_column > div {
|
||||||
|
/* change div to reference your elements you put in <Masonry> */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
// progress
|
// progress
|
||||||
.progress-indicator-container {
|
.progress-indicator-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
96
src/client/components/AirDCPPSettings/AirDCPPHubsForm.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import Select from "react-select";
|
||||||
|
import { saveSettings } from "../../actions/settings.actions";
|
||||||
|
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||||
|
|
||||||
|
export const AirDCPPHubsForm = (airDCPPClientUserSettings): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [hubList, setHubList] = useState([]);
|
||||||
|
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||||
|
const {
|
||||||
|
airDCPPState: { settings, socket },
|
||||||
|
} = airDCPPConfiguration;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!isEmpty(settings)) {
|
||||||
|
const hubs = await socket.get(`hubs`);
|
||||||
|
const hubSelectionOptions = hubs.map(({ hub_url, identity }) => ({
|
||||||
|
value: hub_url,
|
||||||
|
label: identity.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setHubList(hubSelectionOptions);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = (values) => {
|
||||||
|
if (!isUndefined(values.hubs)) {
|
||||||
|
dispatch(saveSettings({ ...settings, hubs: values.hubs }, settings._id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = async () => {};
|
||||||
|
|
||||||
|
const SelectAdapter = ({ input, ...rest }) => {
|
||||||
|
return <Select {...input} {...rest} isClearable isMulti />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validate={validate}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<h3 className="title">Hubs</h3>
|
||||||
|
<h6 className="subtitle has-text-grey-light">
|
||||||
|
Select the hubs you want to perform searches against.
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">AirDC++ Host</label>
|
||||||
|
<div className="control">
|
||||||
|
<Field
|
||||||
|
name="hubs"
|
||||||
|
component={SelectAdapter}
|
||||||
|
className="basic-multi-select"
|
||||||
|
placeholder="Select Hubs to Search Against"
|
||||||
|
options={hubList}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="button is-primary">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<article className="message is-warning">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
Your selection in the dropdown <strong>will replace</strong> the
|
||||||
|
existing selection.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div className="box mt-3">
|
||||||
|
<h6>Selected hubs</h6>
|
||||||
|
{settings.directConnect.client.hubs.map(({ value, label }) => (
|
||||||
|
<div key={value}>
|
||||||
|
<div>{label}</div>
|
||||||
|
<span className="is-size-7">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AirDCPPHubsForm;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
|
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
|
||||||
|
const { settings } = settingsObject;
|
||||||
|
console.log(settings);
|
||||||
|
return (
|
||||||
|
<div className="mt-4 is-clearfix">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-content">
|
||||||
|
<span className="tag is-pulled-right is-primary">Connected</span>
|
||||||
|
<div className="content is-size-7">
|
||||||
|
<dl>
|
||||||
|
<dt>{settings._id}</dt>
|
||||||
|
<dt>Client version: {settings.system_info.client_version}</dt>
|
||||||
|
<dt>Hostname: {settings.system_info.hostname}</dt>
|
||||||
|
<dt>Platform: {settings.system_info.platform}</dt>
|
||||||
|
|
||||||
|
<dt>Username: {settings.user.username}</dt>
|
||||||
|
|
||||||
|
<dt>Active Sessions: {settings.user.active_sessions}</dt>
|
||||||
|
<dt>
|
||||||
|
Permissions:{" "}
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(settings.user.permissions, undefined, 2)}
|
||||||
|
</pre>
|
||||||
|
</dt>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AirDCPPSettingsConfirmation;
|
||||||
154
src/client/components/AirDCPPSettings/AirDCPPSettingsForm.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { ReactElement, useCallback, useContext } from "react";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { saveSettings, deleteSettings } from "../../actions/settings.actions";
|
||||||
|
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||||
|
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||||
|
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||||
|
|
||||||
|
export const AirDCPPSettingsForm = (): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const airDCPPSettings = useContext(AirDCPPSocketContext);
|
||||||
|
|
||||||
|
const hostValidator = (hostname: string): string | null => {
|
||||||
|
const hostnameRegex = /[\W]+/gm;
|
||||||
|
try {
|
||||||
|
if (!isUndefined(hostname)) {
|
||||||
|
const matches = hostname.match(hostnameRegex);
|
||||||
|
return (isNil(matches) && matches.length !== 0) ? hostname : "Invalid hostname; it should not contain special characters";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = useCallback(async (values) => {
|
||||||
|
try {
|
||||||
|
airDCPPSettings.setSettings(values);
|
||||||
|
dispatch(
|
||||||
|
saveSettings({
|
||||||
|
host: values,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const removeSettings = useCallback(async () => {
|
||||||
|
airDCPPSettings.setSettings({});
|
||||||
|
dispatch(deleteSettings());
|
||||||
|
}, []);
|
||||||
|
const validate = async () => { };
|
||||||
|
const initFormData = !isUndefined(
|
||||||
|
airDCPPSettings.airDCPPState.settings.directConnect,
|
||||||
|
)
|
||||||
|
? airDCPPSettings.airDCPPState.settings.directConnect.client.host
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validate={validate}
|
||||||
|
initialValues={initFormData}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<h2>AirDC++ Connection Information</h2>
|
||||||
|
<label className="label">AirDC++ Hostname</label>
|
||||||
|
<div className="field has-addons">
|
||||||
|
<p className="control">
|
||||||
|
<span className="select">
|
||||||
|
<Field name="protocol" component="select">
|
||||||
|
<option>Protocol</option>
|
||||||
|
<option value="http">http://</option>
|
||||||
|
<option value="https">https://</option>
|
||||||
|
</Field>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="control is-expanded">
|
||||||
|
<Field
|
||||||
|
name="hostname"
|
||||||
|
validate={hostValidator}>
|
||||||
|
{({ input, meta }) => (
|
||||||
|
<div>
|
||||||
|
<input {...input} type="text" placeholder="AirDC++ hostname" className="input" />
|
||||||
|
{meta.error && meta.touched && <span className="is-size-7 has-text-danger">{meta.error}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p className="control">
|
||||||
|
<Field
|
||||||
|
name="port"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
placeholder="AirDC++ port"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="is-clearfix">
|
||||||
|
<label className="label">Credentials</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left">
|
||||||
|
<Field
|
||||||
|
name="username"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
placeholder="Username"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-user-ninja"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left has-icons-right">
|
||||||
|
<Field
|
||||||
|
name="password"
|
||||||
|
component="input"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-lock"></i>
|
||||||
|
</span>
|
||||||
|
<span className="icon is-small is-right">
|
||||||
|
<i className="fas fa-check"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field is-grouped">
|
||||||
|
<p className="control">
|
||||||
|
<button type="submit" className="button is-primary">
|
||||||
|
{!isEmpty(initFormData) ? "Update" : "Save"}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
|
||||||
|
<AirDCPPSettingsConfirmation
|
||||||
|
settings={airDCPPSettings.airDCPPState.socketConnectionInformation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
|
||||||
|
<p className="control mt-4">
|
||||||
|
<button className="button is-danger" onClick={removeSettings}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AirDCPPSettingsForm;
|
||||||
@@ -1,36 +1,140 @@
|
|||||||
import * as React from "react";
|
import React, { ReactElement, useContext, useEffect } from "react";
|
||||||
import { hot } from "react-hot-loader";
|
import Dashboard from "./Dashboard/Dashboard";
|
||||||
import Dashboard from "./Dashboard";
|
|
||||||
|
|
||||||
import Import from "./Import";
|
import Import from "./Import";
|
||||||
import { ComicDetail } from "./ComicDetail";
|
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
|
||||||
|
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
|
||||||
|
import LibraryGrid from "./Library/LibraryGrid";
|
||||||
|
import Search from "./Search";
|
||||||
|
import Settings from "./Settings";
|
||||||
|
import VolumeDetail from "./VolumeDetail/VolumeDetail";
|
||||||
|
import Downloads from "./Downloads/Downloads";
|
||||||
|
|
||||||
import { Switch, Route } from "react-router";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import Navbar from "./Navbar";
|
import Navbar from "./Navbar";
|
||||||
import "../assets/scss/App.scss";
|
import "../assets/scss/App.scss";
|
||||||
|
import {
|
||||||
|
AirDCPPSocketContextProvider,
|
||||||
|
AirDCPPSocketContext,
|
||||||
|
} from "../context/AirDCPPSocket";
|
||||||
|
import { isEmpty, isUndefined } from "lodash";
|
||||||
|
import {
|
||||||
|
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||||
|
LS_SINGLE_IMPORT,
|
||||||
|
} from "../constants/action-types";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
class App extends React.Component {
|
/**
|
||||||
public render() {
|
* Method that initializes an AirDC++ socket connection
|
||||||
return (
|
* 1. Initializes event listeners for download init, tick and complete events
|
||||||
|
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const AirDCPPSocketComponent = (): ReactElement => {
|
||||||
|
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeAirDCPPEventListeners = async () => {
|
||||||
|
if (
|
||||||
|
!isUndefined(airDCPPConfiguration.airDCPPState) &&
|
||||||
|
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
|
||||||
|
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
|
||||||
|
) {
|
||||||
|
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
||||||
|
"queue",
|
||||||
|
"queue_bundle_added",
|
||||||
|
async (data) => {
|
||||||
|
console.log("JEMEN:", data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// download tick listener
|
||||||
|
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
||||||
|
`queue`,
|
||||||
|
"queue_bundle_tick",
|
||||||
|
async (downloadProgressData) => {
|
||||||
|
dispatch({
|
||||||
|
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||||
|
downloadProgressData,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// download complete listener
|
||||||
|
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
||||||
|
`queue`,
|
||||||
|
"queue_bundle_status",
|
||||||
|
async (bundleData) => {
|
||||||
|
let count = 0;
|
||||||
|
if (bundleData.status.completed && bundleData.status.downloaded) {
|
||||||
|
// dispatch the action for raw import, with the metadata
|
||||||
|
if (count < 1) {
|
||||||
|
console.log(`[AirDCPP]: Download complete.`);
|
||||||
|
dispatch({
|
||||||
|
type: LS_SINGLE_IMPORT,
|
||||||
|
meta: { remote: true },
|
||||||
|
data: bundleData,
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"[AirDCPP]: Listener registered - listening to queue bundle changes",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"[AirDCPP]: Listener registered - listening to transfer completion",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initializeAirDCPPEventListeners();
|
||||||
|
}, [airDCPPConfiguration]);
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
export const App = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<AirDCPPSocketContextProvider>
|
||||||
<div>
|
<div>
|
||||||
|
<AirDCPPSocketComponent />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Switch>
|
<Routes>
|
||||||
<Route exact path="/">
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Dashboard />
|
<Route path="/import" element={<Import path={"./comics"} />} />
|
||||||
</Route>
|
<Route
|
||||||
<Route path="/import">
|
path="/library"
|
||||||
<Import path={"./comics"} />
|
element={<TabulatedContentContainer category="library" />}
|
||||||
</Route>
|
/>
|
||||||
|
<Route path="/library-grid" element={<LibraryGrid />} />
|
||||||
|
<Route path="/downloads" element={<Downloads data={{}} />} />
|
||||||
|
<Route path="/search" element={<Search />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/comic/details/:comicObjectId"}
|
path={"/comic/details/:comicObjectId"}
|
||||||
component={ComicDetail}
|
element={<ComicDetailContainer />}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
<Route
|
||||||
|
path={"/volume/details/:comicObjectId"}
|
||||||
|
element={<VolumeDetail />}
|
||||||
|
/>
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route
|
||||||
|
path="/pull-list/all"
|
||||||
|
element={<TabulatedContentContainer category="pullList" />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/wanted/all"
|
||||||
|
element={<TabulatedContentContainer category="wanted" />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/volumes/all"
|
||||||
|
element={<TabulatedContentContainer category="volumes" />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
</AirDCPPSocketContextProvider>
|
||||||
}
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
declare let module: Record<string, unknown>;
|
export default App;
|
||||||
|
|
||||||
export default hot(module)(App);
|
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import {
|
|||||||
removeLeadingPeriod,
|
removeLeadingPeriod,
|
||||||
escapePoundSymbol,
|
escapePoundSymbol,
|
||||||
} from "../shared/utils/formatting.utils";
|
} from "../shared/utils/formatting.utils";
|
||||||
import { isUndefined, isEmpty } from "lodash";
|
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
comicBookCoversMetadata: IExtractedComicBookCoverFile;
|
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
|
||||||
mongoObjId?: number;
|
mongoObjId?: number;
|
||||||
|
hasTitle: boolean;
|
||||||
|
title?: string;
|
||||||
|
isHorizontal: boolean;
|
||||||
}
|
}
|
||||||
interface IState {}
|
interface IState {}
|
||||||
|
|
||||||
@@ -22,34 +26,30 @@ class Card extends React.Component<IProps, IState> {
|
|||||||
public drawCoverCard = (
|
public drawCoverCard = (
|
||||||
metadata: IExtractedComicBookCoverFile,
|
metadata: IExtractedComicBookCoverFile,
|
||||||
): JSX.Element => {
|
): JSX.Element => {
|
||||||
const filePath = encodeURI(
|
const encodedFilePath = encodeURI(
|
||||||
"http://localhost:3000" +
|
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
|
||||||
removeLeadingPeriod(metadata.path) +
|
|
||||||
"/" +
|
|
||||||
metadata.name,
|
|
||||||
);
|
);
|
||||||
|
const filePath = escapePoundSymbol(encodedFilePath);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card generic-card">
|
<div className="card generic-card">
|
||||||
<div>
|
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
|
||||||
<div className="card-image">
|
<div className="card-image">
|
||||||
<figure className="image">
|
<figure className="image">
|
||||||
<img
|
<img src={filePath} alt="Placeholder image" />
|
||||||
src={escapePoundSymbol(filePath)}
|
|
||||||
alt="Placeholder image"
|
|
||||||
/>
|
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-content">
|
{this.props.hasTitle && (
|
||||||
<ul>
|
<div className="card-content">
|
||||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
<ul>
|
||||||
<li className="has-text-weight-semibold">
|
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
||||||
{ellipsize(metadata.name, 18)}
|
<li className="has-text-weight-semibold">
|
||||||
</li>
|
{ellipsize(metadata.name, 18)}
|
||||||
</Link>
|
</li>
|
||||||
</ul>
|
</Link>
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
92
src/client/components/Carda.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { isEmpty, isNil } from "lodash";
|
||||||
|
|
||||||
|
interface ICardProps {
|
||||||
|
orientation: string;
|
||||||
|
imageUrl: string;
|
||||||
|
hasDetails: boolean;
|
||||||
|
title?: PropTypes.ReactElementLike | null;
|
||||||
|
children?: PropTypes.ReactNodeLike;
|
||||||
|
borderColorClass?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
cardContainerStyle?: PropTypes.object;
|
||||||
|
imageStyle?: PropTypes.object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCard = (props): ReactElement => {
|
||||||
|
switch (props.orientation) {
|
||||||
|
case "horizontal":
|
||||||
|
return (
|
||||||
|
<div className="card-container">
|
||||||
|
<div className="card generic-card">
|
||||||
|
<div className="is-horizontal">
|
||||||
|
<div className="card-image">
|
||||||
|
<img
|
||||||
|
style={props.imageStyle}
|
||||||
|
src={props.imageUrl}
|
||||||
|
alt="Placeholder image"
|
||||||
|
className="cropped-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{props.hasDetails && (
|
||||||
|
<div className="card-content">{props.children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "vertical":
|
||||||
|
return (
|
||||||
|
<div onClick={props.onClick}>
|
||||||
|
<div className="generic-card" style={props.cardContainerStyle}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
!isNil(props.borderColorClass)
|
||||||
|
? `${props.borderColorClass}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
props.hasDetails
|
||||||
|
? "partial-rounded-card-image"
|
||||||
|
: "rounded-card-image"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<figure>
|
||||||
|
<img
|
||||||
|
src={props.imageUrl}
|
||||||
|
style={props.imageStyle}
|
||||||
|
alt="Placeholder image"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
{props.hasDetails && (
|
||||||
|
<div
|
||||||
|
className="card-content"
|
||||||
|
style={{ backgroundColor: props.backgroundColor }}
|
||||||
|
>
|
||||||
|
{!isNil(props.title) ? (
|
||||||
|
<div className="card-title is-size-8 is-family-secondary">
|
||||||
|
{props.title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Card = (props: ICardProps): ReactElement => {
|
||||||
|
return renderCard(props);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, ReactElement } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import axios from "axios";
|
|
||||||
import Card from "./Card";
|
|
||||||
import MatchResult from "./MatchResult";
|
|
||||||
import ComicVineSearchForm from "./ComicVineSearchForm";
|
|
||||||
|
|
||||||
import { css } from "@emotion/react";
|
|
||||||
import PuffLoader from "react-spinners/PuffLoader";
|
|
||||||
import { isEmpty, isUndefined } from "lodash";
|
|
||||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
|
||||||
import { fetchComicVineMatches } from "../actions/fileops.actions";
|
|
||||||
import { Drawer, Divider } from "antd";
|
|
||||||
const prettyBytes = require("pretty-bytes");
|
|
||||||
import "antd/dist/antd.css";
|
|
||||||
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
|
|
||||||
type ComicDetailProps = {};
|
|
||||||
|
|
||||||
export const ComicDetail = ({}: ComicDetailProps): ReactElement => {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const [comicDetail, setComicDetail] = useState<{
|
|
||||||
rawFileDetails: IExtractedComicBookCoverFile;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const comicVineSearchResults = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.searchResults,
|
|
||||||
);
|
|
||||||
const comicVineSearchQueryObject = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.searchQuery,
|
|
||||||
);
|
|
||||||
const comicVineAPICallProgress = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.inProgress,
|
|
||||||
);
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
axios
|
|
||||||
.request({
|
|
||||||
url: `http://localhost:3000/api/import/getComicBookById`,
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
id: comicObjectId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
setComicDetail(response.data);
|
|
||||||
})
|
|
||||||
.catch((error) => console.log(error));
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const openDrawerWithCVMatches = useCallback(() => {
|
|
||||||
setVisible(true);
|
|
||||||
dispatch(fetchComicVineMatches(comicDetail));
|
|
||||||
}, [dispatch, comicDetail]);
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
setVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="container">
|
|
||||||
{!isEmpty(comicDetail) && !isUndefined(comicDetail) && (
|
|
||||||
<>
|
|
||||||
<h1 className="title">{comicDetail.rawFileDetails.name}</h1>
|
|
||||||
<div className="columns">
|
|
||||||
<div className="column is-narrow">
|
|
||||||
<Card comicBookCoversMetadata={comicDetail.rawFileDetails} />
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<p>{comicDetail.rawFileDetails.containedIn}</p>
|
|
||||||
<p>{prettyBytes(comicDetail.rawFileDetails.fileSize)}</p>
|
|
||||||
<button className="button" onClick={openDrawerWithCVMatches}>
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-magic"></i>
|
|
||||||
</span>
|
|
||||||
<span>Match on Comic Vine</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
title="ComicVine Search Results"
|
|
||||||
placement="right"
|
|
||||||
width={640}
|
|
||||||
closable={false}
|
|
||||||
onClose={onClose}
|
|
||||||
visible={visible}
|
|
||||||
className="comic-vine-match-drawer"
|
|
||||||
>
|
|
||||||
{!isEmpty(comicVineSearchQueryObject) &&
|
|
||||||
!isUndefined(comicVineSearchQueryObject) ? (
|
|
||||||
<div className="card search-criteria-card">
|
|
||||||
<div className="card-content">
|
|
||||||
<ComicVineSearchForm />
|
|
||||||
<Divider />
|
|
||||||
<p className="is-size-6">Searching against:</p>
|
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Title</span>
|
|
||||||
<span className="tag is-info">
|
|
||||||
{
|
|
||||||
comicVineSearchQueryObject.issue.searchParams
|
|
||||||
.searchTerms.name
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Number</span>
|
|
||||||
<span className="tag is-info">
|
|
||||||
{
|
|
||||||
comicVineSearchQueryObject.issue.searchParams
|
|
||||||
.searchTerms.number
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="progress-indicator-container">
|
|
||||||
<div className="indicator">
|
|
||||||
<PuffLoader loading={comicVineAPICallProgress} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="search-results-container">
|
|
||||||
{!isEmpty(comicVineSearchResults) && (
|
|
||||||
<MatchResult matchData={comicVineSearchResults} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
339
src/client/components/ComicDetail/AcquisitionPanel.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
ReactElement,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
search,
|
||||||
|
downloadAirDCPPItem,
|
||||||
|
getBundlesForComic,
|
||||||
|
} from "../../actions/airdcpp.actions";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { isEmpty, isNil, map } from "lodash";
|
||||||
|
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||||
|
|
||||||
|
interface IAcquisitionPanelProps {
|
||||||
|
query: any;
|
||||||
|
comicObjectId: any;
|
||||||
|
comicObject: any;
|
||||||
|
settings: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AcquisitionPanel = (
|
||||||
|
props: IAcquisitionPanelProps,
|
||||||
|
): ReactElement => {
|
||||||
|
const issueName = props.query.issue.name || "";
|
||||||
|
// const { settings } = props;
|
||||||
|
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||||
|
|
||||||
|
// Selectors for picking state
|
||||||
|
const airDCPPSearchResults = useSelector((state: RootState) => {
|
||||||
|
return state.airdcpp.searchResults;
|
||||||
|
});
|
||||||
|
const isAirDCPPSearchInProgress = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
|
||||||
|
);
|
||||||
|
const searchInfo = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.searchInfo,
|
||||||
|
);
|
||||||
|
const searchInstance: SearchInstance = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.searchInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
// const settings = useSelector((state: RootState) => state.settings.data);
|
||||||
|
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [dcppQuery, setDcppQuery] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
|
||||||
|
// AirDC++ search query
|
||||||
|
const dcppSearchQuery = {
|
||||||
|
query: {
|
||||||
|
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||||
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
|
},
|
||||||
|
hub_urls: map(
|
||||||
|
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
||||||
|
(item) => item.value,
|
||||||
|
),
|
||||||
|
priority: 5,
|
||||||
|
};
|
||||||
|
setDcppQuery(dcppSearchQuery);
|
||||||
|
}
|
||||||
|
}, [airDCPPConfiguration]);
|
||||||
|
|
||||||
|
const getDCPPSearchResults = useCallback(
|
||||||
|
async (searchQuery) => {
|
||||||
|
const manualQuery = {
|
||||||
|
query: {
|
||||||
|
pattern: `${searchQuery.issueName}`,
|
||||||
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
|
},
|
||||||
|
hub_urls: map(
|
||||||
|
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
||||||
|
(item) => item.value,
|
||||||
|
),
|
||||||
|
priority: 5,
|
||||||
|
};
|
||||||
|
dispatch(
|
||||||
|
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
|
||||||
|
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||||
|
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, airDCPPConfiguration],
|
||||||
|
);
|
||||||
|
|
||||||
|
// download via AirDC++
|
||||||
|
const downloadDCPPResult = useCallback(
|
||||||
|
(searchInstanceId, resultId, name, size, type) => {
|
||||||
|
dispatch(
|
||||||
|
downloadAirDCPPItem(
|
||||||
|
searchInstanceId,
|
||||||
|
resultId,
|
||||||
|
props.comicObjectId,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
airDCPPConfiguration.airDCPPState.socket,
|
||||||
|
{
|
||||||
|
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||||
|
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// this is to update the download count badge on the downloads tab
|
||||||
|
dispatch(
|
||||||
|
getBundlesForComic(
|
||||||
|
props.comicObjectId,
|
||||||
|
airDCPPConfiguration.airDCPPState.socket,
|
||||||
|
{
|
||||||
|
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||||
|
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[airDCPPConfiguration],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="comic-detail columns">
|
||||||
|
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
|
||||||
|
<Form
|
||||||
|
onSubmit={getDCPPSearchResults}
|
||||||
|
initialValues={{
|
||||||
|
issueName,
|
||||||
|
}}
|
||||||
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="column is-three-quarters"
|
||||||
|
>
|
||||||
|
<div className="box search">
|
||||||
|
<div className="columns">
|
||||||
|
<Field name="issueName">
|
||||||
|
{({ input, meta }) => {
|
||||||
|
return (
|
||||||
|
<div className="column is-two-thirds">
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
className="input main-search-bar is-medium"
|
||||||
|
placeholder="Type an issue/volume name"
|
||||||
|
/>
|
||||||
|
<span className="help is-clearfix is-light is-info">
|
||||||
|
Use this to perform a manual search.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="column">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={
|
||||||
|
isAirDCPPSearchInProgress
|
||||||
|
? "button is-loading is-warning"
|
||||||
|
: "button"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="icon is-small">
|
||||||
|
<img src="/src/client/assets/img/airdcpp_logo.svg" />
|
||||||
|
</span>
|
||||||
|
<span className="airdcpp-text">Search on AirDC++</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="column is-three-fifths">
|
||||||
|
<article className="message is-info">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
AirDC++ is not configured. Please configure it in{" "}
|
||||||
|
<code>Settings</code>.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AirDC++ search instance details */}
|
||||||
|
{!isNil(searchInfo) && !isNil(searchInstance) && (
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-one-quarter is-size-7">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-content">
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<div className="tags mb-1">
|
||||||
|
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
|
||||||
|
({ value }) => (
|
||||||
|
<span className="tag is-warning" key={value}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dt>
|
||||||
|
<dt>
|
||||||
|
Query:{" "}
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{searchInfo.query.pattern}
|
||||||
|
</span>
|
||||||
|
</dt>
|
||||||
|
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
|
||||||
|
<dd>File type: {searchInfo.query.file_type}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="column is-one-quarter is-size-7">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-content">
|
||||||
|
<dl>
|
||||||
|
<dt>Search Instance: {searchInstance.id}</dt>
|
||||||
|
<dt>Owned by {searchInstance.owner}</dt>
|
||||||
|
<dd>Expires in: {searchInstance.expires_in}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AirDC++ results */}
|
||||||
|
<div className="columns">
|
||||||
|
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||||
|
<div className="column">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Slots</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{map(airDCPPSearchResults, ({ result }, idx) => {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={
|
||||||
|
!isNil(result.dupe) ? "dupe-search-result" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<p className="mb-2">
|
||||||
|
{result.type.id === "directory" ? (
|
||||||
|
<i className="fas fa-folder"></i>
|
||||||
|
) : null}{" "}
|
||||||
|
{ellipsize(result.name, 70)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl>
|
||||||
|
<dd>
|
||||||
|
<div className="tags">
|
||||||
|
{!isNil(result.dupe) ? (
|
||||||
|
<span className="tag is-warning">Dupe</span>
|
||||||
|
) : null}
|
||||||
|
<span className="tag is-light is-info">
|
||||||
|
{result.users.user.nicks}
|
||||||
|
</span>
|
||||||
|
{result.users.user.flags.map((flag, idx) => (
|
||||||
|
<span className="tag is-light" key={idx}>
|
||||||
|
{flag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="tag is-light is-info">
|
||||||
|
{result.type.id === "directory"
|
||||||
|
? "directory"
|
||||||
|
: result.type.str}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-success">
|
||||||
|
{result.slots.free} free
|
||||||
|
</span>
|
||||||
|
<span className="tag is-light">
|
||||||
|
{result.slots.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
onClick={() =>
|
||||||
|
downloadDCPPResult(
|
||||||
|
searchInstance.id,
|
||||||
|
result.id,
|
||||||
|
result.name,
|
||||||
|
result.size,
|
||||||
|
result.type,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className="fas fa-file-download"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="column is-three-fifths">
|
||||||
|
<article className="message is-info">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
Searching via <strong>AirDC++</strong> is still in{" "}
|
||||||
|
<strong>alpha</strong>. Some searches may take arbitrarily long,
|
||||||
|
or may not work at all. Searches from <code>ADCS</code> hubs are
|
||||||
|
more reliable than <code>NMDCS</code> ones.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AcquisitionPanel;
|
||||||
93
src/client/components/ComicDetail/ActionMenu/Menu.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { filter, isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import React, { ReactElement, useCallback } from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import Select, { components } from "react-select";
|
||||||
|
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
|
||||||
|
import { refineQuery } from "filename-parser";
|
||||||
|
|
||||||
|
export const Menu = (props): ReactElement => {
|
||||||
|
const { data } = props;
|
||||||
|
const { setSlidingPanelContentId, setVisible } = props.handlers;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const openDrawerWithCVMatches = useCallback(() => {
|
||||||
|
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||||
|
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||||
|
|
||||||
|
if (!isUndefined(data.rawFileDetails)) {
|
||||||
|
issueSearchQuery = refineQuery(data.rawFileDetails.name);
|
||||||
|
} else if (!isEmpty(data.sourcedMetadata)) {
|
||||||
|
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
|
||||||
|
}
|
||||||
|
dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
|
||||||
|
setSlidingPanelContentId("CVMatches");
|
||||||
|
setVisible(true);
|
||||||
|
}, [dispatch, data]);
|
||||||
|
|
||||||
|
const openEditMetadataPanel = useCallback(() => {
|
||||||
|
setSlidingPanelContentId("editComicBookMetadata");
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
// Actions menu options and handler
|
||||||
|
const CVMatchLabel = (
|
||||||
|
<span>
|
||||||
|
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const editLabel = (
|
||||||
|
<span>
|
||||||
|
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const deleteLabel = (
|
||||||
|
<span>
|
||||||
|
<i className="fa-regular fa-trash-alt"></i> Delete Comic
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const Placeholder = (props) => {
|
||||||
|
return <components.Placeholder {...props} />;
|
||||||
|
};
|
||||||
|
const actionOptions = [
|
||||||
|
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||||
|
{ value: "edit-metdata", label: editLabel },
|
||||||
|
{ value: "delete-comic", label: deleteLabel },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||||
|
if (isUndefined(data.rawFileDetails)) {
|
||||||
|
return item.value !== "match-on-comic-vine";
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
const handleActionSelection = (action) => {
|
||||||
|
switch (action.value) {
|
||||||
|
case "match-on-comic-vine":
|
||||||
|
openDrawerWithCVMatches();
|
||||||
|
break;
|
||||||
|
case "edit-metdata":
|
||||||
|
openEditMetadataPanel();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("No valid action selected.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
className="basic-single"
|
||||||
|
classNamePrefix="select"
|
||||||
|
components={{ Placeholder }}
|
||||||
|
placeholder={
|
||||||
|
<span>
|
||||||
|
<i className="fa-solid fa-list"></i> Actions
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
name="actions"
|
||||||
|
isSearchable={false}
|
||||||
|
options={filteredActionOptions}
|
||||||
|
onChange={handleActionSelection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Menu;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { ReactElement, useCallback, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||||
|
import Creatable from "react-select/creatable";
|
||||||
|
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||||
|
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||||
|
|
||||||
|
export const AsyncSelectPaginate = (props): ReactElement => {
|
||||||
|
const [value, setValue] = useState(null);
|
||||||
|
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback((query, loadedOptions, { page }) => {
|
||||||
|
return fetchMetronResource({
|
||||||
|
method: "GET",
|
||||||
|
resource: props.metronResource,
|
||||||
|
query: {
|
||||||
|
name: query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableAsyncPaginate
|
||||||
|
SelectComponent={Creatable}
|
||||||
|
debounceTimeout={200}
|
||||||
|
isDisabled={isAddingInProgress}
|
||||||
|
value={props.value}
|
||||||
|
loadOptions={loadData}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
// onCreateOption={onCreateOption}
|
||||||
|
onChange={props.onChange}
|
||||||
|
// cacheUniqs={[cacheUniq]}
|
||||||
|
additional={{
|
||||||
|
page: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AsyncSelectPaginate.propTypes = {
|
||||||
|
metronResource: PropTypes.string.isRequired,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
value: PropTypes.object,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AsyncSelectPaginate;
|
||||||
320
src/client/components/ComicDetail/ComicDetail.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState, ReactElement, useCallback } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||||
|
|
||||||
|
import { RawFileDetails } from "./RawFileDetails";
|
||||||
|
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||||
|
|
||||||
|
import TabControls from "./TabControls";
|
||||||
|
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||||
|
import { Menu } from "./ActionMenu/Menu";
|
||||||
|
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
||||||
|
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
||||||
|
import AcquisitionPanel from "./AcquisitionPanel";
|
||||||
|
import DownloadsPanel from "./DownloadsPanel";
|
||||||
|
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||||
|
|
||||||
|
import { isEmpty, isUndefined, isNil } from "lodash";
|
||||||
|
import { RootState } from "threetwo-ui-typings";
|
||||||
|
|
||||||
|
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||||
|
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||||
|
import Loader from "react-loader-spinner";
|
||||||
|
import SlidingPane from "react-sliding-pane";
|
||||||
|
import Modal from "react-modal";
|
||||||
|
import ComicViewer from "react-comic-viewer";
|
||||||
|
|
||||||
|
import { extractComicArchive } from "../../actions/fileops.actions";
|
||||||
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
|
||||||
|
type ComicDetailProps = {};
|
||||||
|
/**
|
||||||
|
* Component for displaying the metadata for a comic in greater detail.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* return (
|
||||||
|
* <ComicDetail/>
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
_id,
|
||||||
|
rawFileDetails,
|
||||||
|
inferredMetadata,
|
||||||
|
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||||
|
},
|
||||||
|
userSettings,
|
||||||
|
} = data;
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
|
const [modalIsOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const comicVineSearchResults = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.searchResults,
|
||||||
|
);
|
||||||
|
const comicVineSearchQueryObject = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.searchQuery,
|
||||||
|
);
|
||||||
|
const comicVineAPICallProgress = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.inProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
const extractedComicBook = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.extractedComicBookArchive.reading,
|
||||||
|
);
|
||||||
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const openModal = useCallback((filePath) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
dispatch(
|
||||||
|
extractComicArchive(filePath, {
|
||||||
|
type: "full",
|
||||||
|
purpose: "reading",
|
||||||
|
imageResizeOptions: {
|
||||||
|
baseWidth: 1024,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const afterOpenModal = useCallback((things) => {
|
||||||
|
// references are now sync'd and can be accessed.
|
||||||
|
// subtitle.style.color = "#f00";
|
||||||
|
console.log("kolaveri", things);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// sliding panel init
|
||||||
|
const contentForSlidingPanel = {
|
||||||
|
CVMatches: {
|
||||||
|
content: (props) => (
|
||||||
|
<>
|
||||||
|
<div className="card search-criteria-card">
|
||||||
|
<div className="card-content">
|
||||||
|
<ComicVineSearchForm data={rawFileDetails} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
|
||||||
|
{inferredMetadata.issue ? (
|
||||||
|
<div className="ml-3">
|
||||||
|
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
|
||||||
|
<span className="tag"> # {inferredMetadata.issue.number} </span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!comicVineAPICallProgress ? (
|
||||||
|
<ComicVineMatchPanel
|
||||||
|
props={{
|
||||||
|
comicVineSearchQueryObject,
|
||||||
|
comicVineAPICallProgress,
|
||||||
|
comicVineSearchResults,
|
||||||
|
comicObjectId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="progress-indicator-container">
|
||||||
|
<div className="indicator">
|
||||||
|
<Loader
|
||||||
|
type="MutatingDots"
|
||||||
|
color="#CCC"
|
||||||
|
secondaryColor="#999"
|
||||||
|
height={100}
|
||||||
|
width={100}
|
||||||
|
visible={comicVineAPICallProgress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
editComicBookMetadata: {
|
||||||
|
content: () => <EditMetadataPanel />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// check for the availability of CV metadata
|
||||||
|
const isComicBookMetadataAvailable =
|
||||||
|
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
|
||||||
|
|
||||||
|
// check for the availability of rawFileDetails
|
||||||
|
const areRawFileDetailsAvailable =
|
||||||
|
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
|
||||||
|
|
||||||
|
const { issueName, url } = determineCoverFile({
|
||||||
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
locg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// query for airdc++
|
||||||
|
const airDCPPQuery = {
|
||||||
|
issue: {
|
||||||
|
name: issueName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tab content and header details
|
||||||
|
const tabGroup = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Volume Information",
|
||||||
|
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||||
|
content: isComicBookMetadataAvailable ? (
|
||||||
|
<VolumeInformation data={data.data} key={1} />
|
||||||
|
) : null,
|
||||||
|
shouldShow: isComicBookMetadataAvailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "ComicInfo.xml",
|
||||||
|
icon: <i className="fa-solid fa-code"></i>,
|
||||||
|
content: (
|
||||||
|
<div className="columns" key={2}>
|
||||||
|
<div className="column is-three-quarters">
|
||||||
|
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
shouldShow: !isEmpty(comicInfo),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
icon: <i className="fa-regular fa-file-archive"></i>,
|
||||||
|
name: "Archive Operations",
|
||||||
|
content: <ArchiveOperations data={data.data} key={3} />,
|
||||||
|
shouldShow: areRawFileDetailsAvailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
icon: <i className="fa-solid fa-floppy-disk"></i>,
|
||||||
|
name: "Acquisition",
|
||||||
|
content: (
|
||||||
|
<AcquisitionPanel
|
||||||
|
query={airDCPPQuery}
|
||||||
|
comicObjectId={_id}
|
||||||
|
comicObject={data.data}
|
||||||
|
userSettings={userSettings}
|
||||||
|
key={4}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
icon: null,
|
||||||
|
name: !isEmpty(data.data) ? (
|
||||||
|
<span className="download-tab-name">Downloads</span>
|
||||||
|
) : (
|
||||||
|
"Downloads"
|
||||||
|
),
|
||||||
|
content: !isNil(data.data) && !isEmpty(data.data) && (
|
||||||
|
<DownloadsPanel
|
||||||
|
data={data.data.acquisition.directconnect}
|
||||||
|
comicObjectId={comicObjectId}
|
||||||
|
key={5}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// filtered Tabs
|
||||||
|
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
||||||
|
|
||||||
|
// Determine which cover image to use:
|
||||||
|
// 1. from the locally imported or
|
||||||
|
// 2. from the CV-scraped version
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="section">
|
||||||
|
{!isNil(data) && !isEmpty(data) && (
|
||||||
|
<>
|
||||||
|
<h1 className="title">{issueName}</h1>
|
||||||
|
<div className="columns is-multiline">
|
||||||
|
<div className="column is-narrow">
|
||||||
|
<Card
|
||||||
|
imageUrl={url}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
cardContainerStyle={{ maxWidth: 275 }}
|
||||||
|
/>
|
||||||
|
{/* action dropdown */}
|
||||||
|
<div className="mt-4 is-size-7">
|
||||||
|
<Menu
|
||||||
|
data={data.data}
|
||||||
|
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* raw file details */}
|
||||||
|
<div className="column">
|
||||||
|
{!isUndefined(rawFileDetails) &&
|
||||||
|
!isEmpty(rawFileDetails.cover) && (
|
||||||
|
<>
|
||||||
|
<RawFileDetails
|
||||||
|
data={{
|
||||||
|
rawFileDetails: rawFileDetails,
|
||||||
|
inferredMetadata: inferredMetadata,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Read comic button */}
|
||||||
|
<button
|
||||||
|
className="button is-success is-light"
|
||||||
|
onClick={() => openModal(rawFileDetails.filePath)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-book-open mr-2"></i>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
style={{ content: { marginTop: "2rem" } }}
|
||||||
|
isOpen={modalIsOpen}
|
||||||
|
onAfterOpen={afterOpenModal}
|
||||||
|
onRequestClose={closeModal}
|
||||||
|
contentLabel="Example Modal"
|
||||||
|
>
|
||||||
|
<button onClick={closeModal}>close</button>
|
||||||
|
{extractedComicBook && (
|
||||||
|
<ComicViewer
|
||||||
|
pages={extractedComicBook}
|
||||||
|
direction="ltr"
|
||||||
|
className={{closeButton: "border: 1px solid red;"}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{<TabControls filteredTabs={filteredTabs} />}
|
||||||
|
|
||||||
|
<SlidingPane
|
||||||
|
isOpen={visible}
|
||||||
|
onRequestClose={() => setVisible(false)}
|
||||||
|
title={"Comic Vine Search Matches"}
|
||||||
|
width={"600px"}
|
||||||
|
>
|
||||||
|
{slidingPanelContentId !== "" &&
|
||||||
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
|
</SlidingPane>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicDetail;
|
||||||
22
src/client/components/ComicDetail/ComicDetailContainer.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
|
||||||
|
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||||
|
|
||||||
|
export const ComicDetailContainer = (): ReactElement | null => {
|
||||||
|
const comicBookDetailData = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||||
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getComicBookDetailById(comicObjectId));
|
||||||
|
// dispatch(getSettings());
|
||||||
|
}, [dispatch]);
|
||||||
|
return !isEmpty(comicBookDetailData) ? (
|
||||||
|
<ComicDetail data={comicBookDetailData} />
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
119
src/client/components/ComicDetail/ComicVineDetails.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { isUndefined } from "lodash";
|
||||||
|
import Card from "../Carda";
|
||||||
|
export const ComicVineDetails = (props): ReactElement => {
|
||||||
|
const { data, updatedAt } = props;
|
||||||
|
return (
|
||||||
|
<div className="column is-half">
|
||||||
|
<div className="comic-detail comicvine-metadata">
|
||||||
|
<dl>
|
||||||
|
<dt>ComicVine Metadata</dt>
|
||||||
|
<dd className="is-size-7">
|
||||||
|
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd>
|
||||||
|
<div className="columns mt-2">
|
||||||
|
<div className="column is-2">
|
||||||
|
<Card
|
||||||
|
imageUrl={data.volumeInformation.image.thumb_url}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="column is-10">
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
Is a part of{" "}
|
||||||
|
<span className="has-text-info">
|
||||||
|
{data.volumeInformation.name}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd>
|
||||||
|
Published by
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{" "}
|
||||||
|
{data.volumeInformation.publisher.name}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
Total issues in this volume:
|
||||||
|
{data.volumeInformation.count_of_issues}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd>
|
||||||
|
<div className="field is-grouped mt-2">
|
||||||
|
{data.issue_number && (
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-light">Issue Number</span>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{data.issue_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUndefined(
|
||||||
|
detectIssueTypes(data.volumeInformation.description),
|
||||||
|
) ? (
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-light">Detected Type</span>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{
|
||||||
|
detectIssueTypes(
|
||||||
|
data.volumeInformation.description,
|
||||||
|
).displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data.resource_type ? (
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-light">Type</span>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{data.resource_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-light">
|
||||||
|
ComicVine Issue ID
|
||||||
|
</span>
|
||||||
|
<span className="tag is-success">{data.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicVineDetails;
|
||||||
|
|
||||||
|
ComicVineDetails.propTypes = {
|
||||||
|
updatedAt: PropTypes.string,
|
||||||
|
data: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
number: PropTypes.string,
|
||||||
|
resource_type: PropTypes.string,
|
||||||
|
id: PropTypes.number,
|
||||||
|
}),
|
||||||
|
};
|
||||||
27
src/client/components/ComicDetail/ComicVineMatchPanel.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||||
|
import MatchResult from "../MatchResult";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
|
||||||
|
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||||
|
const {
|
||||||
|
comicObjectId,
|
||||||
|
comicVineSearchQueryObject,
|
||||||
|
comicVineAPICallProgress,
|
||||||
|
comicVineSearchResults,
|
||||||
|
} = comicVineData.props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="search-results-container">
|
||||||
|
{!isEmpty(comicVineSearchResults) && (
|
||||||
|
<MatchResult
|
||||||
|
matchData={comicVineSearchResults}
|
||||||
|
comicObjectId={comicObjectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicVineMatchPanel;
|
||||||
35
src/client/components/ComicDetail/DownloadProgressTick.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
|
export const DownloadProgressTick = (props): ReactElement => {
|
||||||
|
return (
|
||||||
|
<div >
|
||||||
|
<h4 className="is-size-6">{props.data.name}</h4>
|
||||||
|
<div>
|
||||||
|
<span className="is-size-3 has-text-weight-semibold">
|
||||||
|
{prettyBytes(props.data.downloaded_bytes)} of{" "}
|
||||||
|
{prettyBytes(props.data.size)}{" "}
|
||||||
|
</span>
|
||||||
|
<progress
|
||||||
|
className="progress is-small is-success"
|
||||||
|
value={props.data.downloaded_bytes}
|
||||||
|
max={props.data.size}
|
||||||
|
>
|
||||||
|
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
|
||||||
|
100}
|
||||||
|
%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div className="is-size-5">
|
||||||
|
{prettyBytes(props.data.speed)} per second.
|
||||||
|
</div>
|
||||||
|
<div className="is-size-5">
|
||||||
|
Time left:
|
||||||
|
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
||||||
|
</div>
|
||||||
|
<div>{props.data.target}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadProgressTick;
|
||||||
106
src/client/components/ComicDetail/DownloadsPanel.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect, useContext, ReactElement } from "react";
|
||||||
|
import { getBundlesForComic } from "../../actions/airdcpp.actions";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { RootState } from "threetwo-ui-typings";
|
||||||
|
import { isEmpty, isNil, map } from "lodash";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||||
|
|
||||||
|
interface IDownloadsPanelProps {
|
||||||
|
data: any;
|
||||||
|
comicObjectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadsPanel = (
|
||||||
|
props: IDownloadsPanelProps,
|
||||||
|
): ReactElement | null => {
|
||||||
|
const bundles = useSelector((state: RootState) => {
|
||||||
|
return state.airdcpp.bundles;
|
||||||
|
});
|
||||||
|
|
||||||
|
// AirDCPP Socket initialization
|
||||||
|
const userSettings = useSelector((state: RootState) => state.settings.data);
|
||||||
|
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||||
|
|
||||||
|
const {
|
||||||
|
airDCPPState: { socket, settings },
|
||||||
|
} = airDCPPConfiguration;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (!isEmpty(userSettings)) {
|
||||||
|
dispatch(
|
||||||
|
getBundlesForComic(props.comicObjectId, socket, {
|
||||||
|
username: `${settings.directConnect.client.host.username}`,
|
||||||
|
password: `${settings.directConnect.client.host.password}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}, [dispatch, airDCPPConfiguration]);
|
||||||
|
|
||||||
|
const Bundles = (props) => {
|
||||||
|
return !isEmpty(props.data) ? (
|
||||||
|
<div className="column is-full">
|
||||||
|
<table className="table is-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Filename</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Download Time</th>
|
||||||
|
<th>Bundle ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{map(props.data, (bundle) => (
|
||||||
|
<tr key={bundle.id}>
|
||||||
|
<td>
|
||||||
|
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||||
|
<span className="is-size-7">{bundle.target}</span>
|
||||||
|
</td>
|
||||||
|
<td>{prettyBytes(bundle.size)}</td>
|
||||||
|
<td>
|
||||||
|
{dayjs
|
||||||
|
.unix(bundle.time_finished)
|
||||||
|
.format("h:mm on ddd, D MMM, YYYY")}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="tag is-warning">{bundle.id}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="column is-full"> {"No Downloads Found"} </div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return !isNil(props.data) ? (
|
||||||
|
<>
|
||||||
|
<div className="columns is-multiline">
|
||||||
|
{!isEmpty(socket) ? (
|
||||||
|
<Bundles data={bundles} />
|
||||||
|
) : (
|
||||||
|
<div className="column is-three-fifths">
|
||||||
|
<article className="message is-info">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
AirDC++ is not configured. Please configure it in{" "}
|
||||||
|
<code>Settings</code>.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadsPanel;
|
||||||
344
src/client/components/ComicDetail/EditMetadataPanel.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import arrayMutators from "final-form-arrays";
|
||||||
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
|
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||||
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
|
||||||
|
export const EditMetadataPanel = (props): ReactElement => {
|
||||||
|
const validate = async () => {};
|
||||||
|
const onSubmit = async () => {};
|
||||||
|
|
||||||
|
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
|
||||||
|
return (
|
||||||
|
<AsyncSelectPaginate
|
||||||
|
{...input}
|
||||||
|
{...rest}
|
||||||
|
onChange={(value) => input.onChange(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
|
||||||
|
return (
|
||||||
|
<TextareaAutosize
|
||||||
|
{...input}
|
||||||
|
{...rest}
|
||||||
|
onChange={(value) => input.onChange(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const rawFileDetails = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||||
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validate={validate}
|
||||||
|
mutators={{
|
||||||
|
...arrayMutators,
|
||||||
|
}}
|
||||||
|
render={({
|
||||||
|
handleSubmit,
|
||||||
|
form: {
|
||||||
|
mutators: { push, pop },
|
||||||
|
}, // injected from final-form-arrays above
|
||||||
|
pristine,
|
||||||
|
form,
|
||||||
|
submitting,
|
||||||
|
values,
|
||||||
|
}) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Issue Name */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Issue Details</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left">
|
||||||
|
<Field
|
||||||
|
name="issue_name"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
initialValue={rawFileDetails}
|
||||||
|
placeholder={"Issue Name"}
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-user-ninja"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Issue Number and year */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label"></div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control has-icons-left">
|
||||||
|
<Field
|
||||||
|
name="issue_number"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
placeholder="Issue Number"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-hashtag"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="help">Do not enter the first zero</p>
|
||||||
|
</div>
|
||||||
|
{/* year */}
|
||||||
|
<div className="field">
|
||||||
|
<p className="control">
|
||||||
|
<Field
|
||||||
|
name="issue_year"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* page count */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label"></div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control has-icons-left">
|
||||||
|
<Field
|
||||||
|
name="page_count"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
placeholder="Page Count"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-note-sticky"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Description</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left">
|
||||||
|
<Field
|
||||||
|
name={"description"}
|
||||||
|
className="textarea"
|
||||||
|
component={TextareaAutosizeAdapter}
|
||||||
|
placeholder={"Description"}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr size="1" />
|
||||||
|
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label">
|
||||||
|
<label className="label">Distributor Info</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field is-expanded">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control has-icons-left">
|
||||||
|
<Field
|
||||||
|
name="distributor_sku"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
placeholder="SKU"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-barcode"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UPC code */}
|
||||||
|
<div className="field">
|
||||||
|
<p className="control has-icons-left">
|
||||||
|
<Field
|
||||||
|
name="upc_code"
|
||||||
|
component="input"
|
||||||
|
className="input"
|
||||||
|
placeholder="UPC Code"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fa-solid fa-box"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr size="1" />
|
||||||
|
|
||||||
|
{/* Publisher */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Publisher</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left">
|
||||||
|
<Field
|
||||||
|
name={"publisher"}
|
||||||
|
component={AsyncSelectPaginateAdapter}
|
||||||
|
placeholder={
|
||||||
|
<div>
|
||||||
|
<i className="fas fa-print mr-2"></i> Publisher
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
metronResource={"publisher"}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arc */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Story Arc</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left">
|
||||||
|
<Field
|
||||||
|
name={"story_arc"}
|
||||||
|
component={AsyncSelectPaginateAdapter}
|
||||||
|
placeholder={
|
||||||
|
<div>
|
||||||
|
<i className="fas fa-book-open mr-2"></i> Story Arc
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
metronResource={"arc"}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* series */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Series</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control is-expanded has-icons-left">
|
||||||
|
<Field
|
||||||
|
name={"series"}
|
||||||
|
component={AsyncSelectPaginateAdapter}
|
||||||
|
placeholder={
|
||||||
|
<div>
|
||||||
|
<i className="fas fa-layer-group mr-2"></i> Series
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
metronResource={"series"}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr size="1" />
|
||||||
|
|
||||||
|
{/* team credits */}
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Team Credits</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body mt-4">
|
||||||
|
<div className="field">
|
||||||
|
<div className="buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button is-small"
|
||||||
|
onClick={() => push("credits", undefined)}
|
||||||
|
>
|
||||||
|
Add credit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button is-small"
|
||||||
|
onClick={() => pop("credits")}
|
||||||
|
>
|
||||||
|
Remove credit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldArray name="credits">
|
||||||
|
{({ fields }) =>
|
||||||
|
fields.map((name, index) => (
|
||||||
|
<div className="field is-horizontal" key={name}>
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label></label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<p className="control">
|
||||||
|
<Field
|
||||||
|
name={`${name}.creator`}
|
||||||
|
component={AsyncSelectPaginateAdapter}
|
||||||
|
placeholder={
|
||||||
|
<div>
|
||||||
|
<i className="fa-solid fa-ghost"></i> Creator
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
metronResource={"creator"}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<p className="control">
|
||||||
|
<Field
|
||||||
|
name={`${name}.role`}
|
||||||
|
metronResource={"role"}
|
||||||
|
placeholder={
|
||||||
|
<div>
|
||||||
|
<i className="fa-solid fa-key"></i> Role
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
component={AsyncSelectPaginateAdapter}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="icon is-danger mt-2"
|
||||||
|
onClick={() => fields.remove(index)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<i className="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</FieldArray>
|
||||||
|
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditMetadataPanel;
|
||||||
106
src/client/components/ComicDetail/RawFileDetails.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
|
||||||
|
export const RawFileDetails = (props): ReactElement => {
|
||||||
|
const { rawFileDetails, inferredMetadata } = props.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="comic-detail raw-file-details column is-three-fifths">
|
||||||
|
<dl>
|
||||||
|
<dt>Raw File Details</dt>
|
||||||
|
<dd className="is-size-7">
|
||||||
|
{rawFileDetails.containedIn +
|
||||||
|
"/" +
|
||||||
|
rawFileDetails.name +
|
||||||
|
rawFileDetails.extension}
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<div className="field is-grouped mt-2">
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">Size</span>
|
||||||
|
<span className="tag is-info is-light">
|
||||||
|
{prettyBytes(rawFileDetails.fileSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">Extension</span>
|
||||||
|
<span className="tag is-primary is-light">
|
||||||
|
{rawFileDetails.extension}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">MIME type</span>
|
||||||
|
<span className="tag is-primary is-light">
|
||||||
|
{rawFileDetails.mimeType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content comic-detail raw-file-details mt-3 column is-three-fifths">
|
||||||
|
<dl>
|
||||||
|
{/* inferred metadata */}
|
||||||
|
<dt>Inferred Issue Metadata</dt>
|
||||||
|
<dd>
|
||||||
|
<div className="field is-grouped mt-2">
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">Name</span>
|
||||||
|
<span className="tag is-info is-light">
|
||||||
|
{inferredMetadata.issue.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isEmpty(inferredMetadata.issue.number) ? (
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">Number</span>
|
||||||
|
<span className="tag is-primary is-light">
|
||||||
|
{inferredMetadata.issue.number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RawFileDetails;
|
||||||
|
|
||||||
|
RawFileDetails.propTypes = {
|
||||||
|
data: PropTypes.shape({
|
||||||
|
rawFileDetails: PropTypes.shape({
|
||||||
|
containedIn: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
fileSize: PropTypes.number,
|
||||||
|
path: PropTypes.string,
|
||||||
|
extension: PropTypes.string,
|
||||||
|
mimeType: PropTypes.string,
|
||||||
|
cover: PropTypes.shape({
|
||||||
|
filePath: PropTypes.string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
inferredMetadata: PropTypes.shape({
|
||||||
|
issue: PropTypes.shape({
|
||||||
|
year: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
number: PropTypes.number,
|
||||||
|
subtitle: PropTypes.string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
52
src/client/components/ComicDetail/TabControls.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { ReactElement, useEffect, useState } from "react";
|
||||||
|
import { isEmpty, isNil } from "lodash";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
export const TabControls = (props): ReactElement => {
|
||||||
|
const comicBookDetailData = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||||
|
);
|
||||||
|
const { filteredTabs } = props;
|
||||||
|
|
||||||
|
const [active, setActive] = useState(filteredTabs[0].id);
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(filteredTabs[0].id);
|
||||||
|
}, [comicBookDetailData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="tabs">
|
||||||
|
<ul>
|
||||||
|
{filteredTabs.map(({ id, name, icon }) => (
|
||||||
|
<li
|
||||||
|
key={id}
|
||||||
|
className={id === active ? "is-active" : ""}
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
>
|
||||||
|
{/* Downloads tab and count badge */}
|
||||||
|
<a>
|
||||||
|
{id === 5 &&
|
||||||
|
!isNil(comicBookDetailData.acquisition.directconnect) ? (
|
||||||
|
<span className="download-icon-labels">
|
||||||
|
<i className="fa-solid fa-download"></i>
|
||||||
|
<span className="tag downloads-count is-info is-light">
|
||||||
|
{comicBookDetailData.acquisition.directconnect.downloads.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="icon is-small">{icon}</span>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{filteredTabs.map(({ id, content }) => {
|
||||||
|
return active === id ? content : null;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabControls;
|
||||||
129
src/client/components/ComicDetail/Tabs/ArchiveOperations.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { ReactElement, useCallback, useState } from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { DnD } from "../../DnD";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import Sticky from "react-stickynode";
|
||||||
|
import SlidingPane from "react-sliding-pane";
|
||||||
|
import { extractComicArchive } from "../../../actions/fileops.actions";
|
||||||
|
import { analyzeImage } from "../../../actions/fileops.actions";
|
||||||
|
import { Canvas } from "../../shared/Canvas";
|
||||||
|
|
||||||
|
export const ArchiveOperations = (props): ReactElement => {
|
||||||
|
const { data } = props;
|
||||||
|
const isComicBookExtractionInProgress = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.comicBookExtractionInProgress,
|
||||||
|
);
|
||||||
|
const extractedComicBookArchive = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageAnalysisResult = useSelector((state: RootState) => {
|
||||||
|
return state.fileOps.imageAnalysisResults;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const unpackComicArchive = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
extractComicArchive(data.rawFileDetails.filePath, {
|
||||||
|
type: "full",
|
||||||
|
purpose: "analysis",
|
||||||
|
imageResizeOptions: {
|
||||||
|
baseWidth: 275,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// sliding panel config
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
|
// current image
|
||||||
|
const [currentImage, setCurrentImage] = useState([]);
|
||||||
|
|
||||||
|
// sliding panel init
|
||||||
|
const contentForSlidingPanel = {
|
||||||
|
imageAnalysis: {
|
||||||
|
content: () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<pre className="is-size-7">{currentImage}</pre>
|
||||||
|
{!isEmpty(imageAnalysisResult) ? (
|
||||||
|
<pre className="is-size-7 p-2 mt-3">
|
||||||
|
<Canvas data={imageAnalysisResult} />
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
<pre className="is-size-7 mt-3">
|
||||||
|
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// sliding panel handlers
|
||||||
|
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||||
|
setSlidingPanelContentId("imageAnalysis");
|
||||||
|
dispatch(analyzeImage(imageFilePath));
|
||||||
|
setCurrentImage(imageFilePath);
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={2}>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
isComicBookExtractionInProgress
|
||||||
|
? "button is-loading is-warning"
|
||||||
|
: "button is-warning"
|
||||||
|
}
|
||||||
|
onClick={unpackComicArchive}
|
||||||
|
>
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fa-solid fa-box-open"></i>
|
||||||
|
</span>
|
||||||
|
<span>Unpack comic archive</span>
|
||||||
|
</button>
|
||||||
|
<div className="columns">
|
||||||
|
<div className="mt-5">
|
||||||
|
{!isEmpty(extractedComicBookArchive) ? (
|
||||||
|
<DnD
|
||||||
|
data={extractedComicBookArchive}
|
||||||
|
onClickHandler={openImageAnalysisPanel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!isEmpty(extractedComicBookArchive) ? (
|
||||||
|
<div className="column mt-5">
|
||||||
|
<Sticky enabled={true} top={70} bottomBoundary={3000}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-content">
|
||||||
|
<span className="has-text-size-4">
|
||||||
|
{extractedComicBookArchive.length} pages
|
||||||
|
</span>
|
||||||
|
<button className="button is-small is-light is-primary is-outlined">
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fa-solid fa-compress"></i>
|
||||||
|
</span>
|
||||||
|
<span>Convert to CBZ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sticky>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SlidingPane
|
||||||
|
isOpen={visible}
|
||||||
|
onRequestClose={() => setVisible(false)}
|
||||||
|
title={"Image Analysis"}
|
||||||
|
width={"600px"}
|
||||||
|
>
|
||||||
|
{slidingPanelContentId !== "" &&
|
||||||
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
|
</SlidingPane>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArchiveOperations;
|
||||||
57
src/client/components/ComicDetail/Tabs/ComicInfoXML.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { isUndefined } from "lodash";
|
||||||
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
|
export const ComicInfoXML = (data): ReactElement => {
|
||||||
|
const { json } = data;
|
||||||
|
return (
|
||||||
|
<div className="comicInfo-metadata">
|
||||||
|
<dl className="has-text-size-7">
|
||||||
|
<dd className="has-text-weight-medium">{json.series[0]}</dd>
|
||||||
|
<dd className="mt-2 mb-2">
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons">
|
||||||
|
<span className="tag">Pages</span>
|
||||||
|
<span className="tag is-warning is-light">
|
||||||
|
{json.publisher[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons">
|
||||||
|
<span className="tag">Issue #</span>
|
||||||
|
<span className="tag is-warning is-light">
|
||||||
|
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons">
|
||||||
|
<span className="tag">Pages</span>
|
||||||
|
<span className="tag is-warning is-light">
|
||||||
|
{json.pagecount[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isUndefined(json.genre) && (
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons">
|
||||||
|
<span className="tag">Genre</span>
|
||||||
|
<span className="tag is-success is-light">
|
||||||
|
{json.genre[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<span className="is-size-7">{json.notes[0]}</span>
|
||||||
|
</dd>
|
||||||
|
<dd className="mt-1 mb-1">{json.summary[0]}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicInfoXML;
|
||||||
32
src/client/components/ComicDetail/Tabs/VolumeInformation.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import ComicVineDetails from "../ComicVineDetails";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
|
||||||
|
export const VolumeInformation = (props): ReactElement => {
|
||||||
|
const { data } = props;
|
||||||
|
const createDescriptionMarkup = (html) => {
|
||||||
|
return { __html: html };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={1}>
|
||||||
|
<div className="columns is-multiline">
|
||||||
|
<ComicVineDetails
|
||||||
|
data={data.sourcedMetadata.comicvine}
|
||||||
|
updatedAt={data.updatedAt}
|
||||||
|
/>
|
||||||
|
<div className="column is-8">
|
||||||
|
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
|
||||||
|
convert(data.sourcedMetadata.comicvine.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeInformation;
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import React from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import Collapsible from "react-collapsible";
|
import Collapsible from "react-collapsible";
|
||||||
|
import { fetchComicVineMatches } from "../actions/fileops.actions";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for accepting ComicVine search parameters
|
* Component for performing search against ComicVine
|
||||||
*
|
*
|
||||||
* @component
|
* @component
|
||||||
* @example
|
* @example
|
||||||
* const age = 21
|
|
||||||
* const name = 'Jitendra Nirnejak'
|
|
||||||
* return (
|
* return (
|
||||||
* <User age={age} name={name} />
|
* <ComicVineSearchForm data={rawFileDetails} />
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
export const ComicVineSearchForm = () => {
|
export const ComicVineSearchForm = (data) => {
|
||||||
const onSubmit = () => {
|
const dispatch = useDispatch();
|
||||||
return true;
|
const onSubmit = useCallback((value) => {
|
||||||
};
|
const userInititatedQuery = {
|
||||||
|
inferredIssueDetails: {
|
||||||
|
name: value.issueName,
|
||||||
|
number: value.issueNumber,
|
||||||
|
subtitle: "",
|
||||||
|
year: value.issueYear,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
||||||
|
}, []);
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -28,7 +37,7 @@ export const ComicVineSearchForm = () => {
|
|||||||
render={({ handleSubmit }) => (
|
render={({ handleSubmit }) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<span className="field is-normal">
|
<span className="field is-normal">
|
||||||
<label className="label">Issue Details</label>
|
<label className="label mb-2 is-size-5">Search Manually</label>
|
||||||
</span>
|
</span>
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
<div className="field-body">
|
<div className="field-body">
|
||||||
@@ -48,11 +57,15 @@ export const ComicVineSearchForm = () => {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-body">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Field name="issueNumber">
|
<Field name="issueNumber">
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<p className="control is-expanded has-icons-left">
|
<p className="control has-icons-left">
|
||||||
<input
|
<input
|
||||||
{...props.input}
|
{...props.input}
|
||||||
className="input is-normal"
|
className="input is-normal"
|
||||||
@@ -65,18 +78,36 @@ export const ComicVineSearchForm = () => {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<Field name="issueYear">
|
||||||
|
{(props) => (
|
||||||
|
<p className="control has-icons-left">
|
||||||
|
<input
|
||||||
|
{...props.input}
|
||||||
|
className="input is-normal"
|
||||||
|
placeholder="Type the issue year"
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fas fa-hashtag"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
<div className="field-body">
|
<div className="field-body">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button is-info is-light is-outlined is-small"
|
className="button is-success is-light is-outlined is-small"
|
||||||
>
|
>
|
||||||
<span className="icon">
|
<span className="icon">
|
||||||
<i className="fas fa-hand-sparkles"></i>
|
<i className="fas fa-search"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -89,16 +120,7 @@ export const ComicVineSearchForm = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <MyForm />;
|
||||||
<Collapsible
|
|
||||||
trigger={"Match Manually"}
|
|
||||||
triggerTagName="a"
|
|
||||||
triggerClassName={"is-size-6"}
|
|
||||||
triggerOpenedClassName={"is-size-6"}
|
|
||||||
>
|
|
||||||
<MyForm />
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ComicVineSearchForm;
|
export default ComicVineSearchForm;
|
||||||
|
|||||||
25
src/client/components/Cover.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
|
||||||
|
export const Cover = forwardRef(
|
||||||
|
({ url, index, faded, style, ...props }, ref) => {
|
||||||
|
const inlineStyles = {
|
||||||
|
opacity: faded ? "0.2" : "1",
|
||||||
|
transformOrigin: "0 0",
|
||||||
|
minHeight: index === 0 ? 300 : 300,
|
||||||
|
maxWidth: 200,
|
||||||
|
gridRowStart: index === 0 ? "span" : null,
|
||||||
|
gridColumnStart: index === 0 ? "span" : null,
|
||||||
|
backgroundImage: `url("${url}")`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundColor: "grey",
|
||||||
|
border: "1px solid #CCC",
|
||||||
|
borderRadius: "10px",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div ref={ref} style={inlineStyles} {...props}></div>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Cover.displayName = "Cover";
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
import ZeroState from "./ZeroState";
|
|
||||||
import { RecentlyImported } from "./RecentlyImported";
|
|
||||||
import { getRecentlyImportedComicBooks } from "../actions/fileops.actions";
|
|
||||||
import { isEmpty } from "lodash";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
getRecentComics: Function;
|
|
||||||
recentComics: any;
|
|
||||||
}
|
|
||||||
interface IState {
|
|
||||||
fileOps: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
class Dashboard extends React.Component<IProps, IState> {
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.getRecentComics();
|
|
||||||
}
|
|
||||||
public render() {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<section className="section">
|
|
||||||
<h1 className="title">Dashboard</h1>
|
|
||||||
|
|
||||||
{!isEmpty(this.props.recentComics) &&
|
|
||||||
!isEmpty(this.props.recentComics.docs) ? (
|
|
||||||
<>
|
|
||||||
<h2 className="subtitle">Recently Imported</h2>
|
|
||||||
<RecentlyImported comicBookCovers={this.props.recentComics} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ZeroState
|
|
||||||
header={"Set the source directory"}
|
|
||||||
message={
|
|
||||||
"No comics were found! Please point ThreeTwo! to a directory..."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state: IState) {
|
|
||||||
return {
|
|
||||||
recentComics: state.fileOps.recentComics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
getRecentComics() {
|
|
||||||
dispatch(
|
|
||||||
getRecentlyImportedComicBooks({
|
|
||||||
paginationOptions: {
|
|
||||||
page: 0,
|
|
||||||
limit: 18,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);
|
|
||||||
97
src/client/components/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { ReactElement, useEffect } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import ZeroState from "./ZeroState";
|
||||||
|
import { RecentlyImported } from "./RecentlyImported";
|
||||||
|
import { WantedComicsList } from "./WantedComicsList";
|
||||||
|
import { VolumeGroups } from "./VolumeGroups";
|
||||||
|
import { LibraryStatistics } from "./LibraryStatistics";
|
||||||
|
import { PullList } from "./PullList";
|
||||||
|
import {
|
||||||
|
fetchVolumeGroups,
|
||||||
|
getComicBooks,
|
||||||
|
} from "../../actions/fileops.actions";
|
||||||
|
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
|
||||||
|
import { isEmpty, isNil } from "lodash";
|
||||||
|
|
||||||
|
export const Dashboard = (): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchVolumeGroups());
|
||||||
|
dispatch(
|
||||||
|
getComicBooks({
|
||||||
|
paginationOptions: {
|
||||||
|
page: 0,
|
||||||
|
limit: 5,
|
||||||
|
sort: { updatedAt: "-1" },
|
||||||
|
},
|
||||||
|
predicate: { "acquisition.source.wanted": false },
|
||||||
|
comicStatus: "recent",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
dispatch(
|
||||||
|
getComicBooks({
|
||||||
|
paginationOptions: {
|
||||||
|
page: 0,
|
||||||
|
limit: 5,
|
||||||
|
sort: { updatedAt: "-1" },
|
||||||
|
},
|
||||||
|
predicate: { "acquisition.source.wanted": true },
|
||||||
|
comicStatus: "wanted",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
dispatch(getLibraryStatistics());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recentComics = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.recentComics
|
||||||
|
);
|
||||||
|
const wantedComics = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.wantedComics,
|
||||||
|
);
|
||||||
|
const volumeGroups = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.comicVolumeGroups,
|
||||||
|
);
|
||||||
|
|
||||||
|
const libraryStatistics = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.libraryStatistics,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<section className="section">
|
||||||
|
<h1 className="title">Dashboard</h1>
|
||||||
|
|
||||||
|
{!isEmpty(recentComics) ? (
|
||||||
|
<>
|
||||||
|
{/* Pull List */}
|
||||||
|
<PullList issues={recentComics} />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{!isEmpty(libraryStatistics) && (
|
||||||
|
<LibraryStatistics stats={libraryStatistics} />
|
||||||
|
)}
|
||||||
|
{/* Wanted comics */}
|
||||||
|
{!isEmpty(wantedComics) && (
|
||||||
|
<WantedComicsList comics={wantedComics} />
|
||||||
|
)}
|
||||||
|
{/* Recent imports */}
|
||||||
|
<RecentlyImported comicBookCovers={recentComics} />
|
||||||
|
|
||||||
|
{/* Volumes */}
|
||||||
|
{!isEmpty(volumeGroups) && (
|
||||||
|
<VolumeGroups volumeGroups={volumeGroups} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ZeroState
|
||||||
|
header={"Set the source directory"}
|
||||||
|
message={
|
||||||
|
"No comics were found! Please point ThreeTwo! to a directory..."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
117
src/client/components/Dashboard/LibraryStatistics.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { ReactElement, useEffect } from "react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { isEmpty, isUndefined, map } from "lodash";
|
||||||
|
|
||||||
|
export const LibraryStatistics = (
|
||||||
|
props: ILibraryStatisticsProps,
|
||||||
|
): ReactElement => {
|
||||||
|
// const { stats } = props;
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<h4 className="title is-4">
|
||||||
|
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
|
||||||
|
</h4>
|
||||||
|
<p className="subtitle is-7">A brief snapshot of your library.</p>
|
||||||
|
<div className="columns is-multiline">
|
||||||
|
<div className="column is-narrow is-two-quarter">
|
||||||
|
<dl className="box">
|
||||||
|
<dd className="is-size-4">
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{props.stats.totalDocuments}
|
||||||
|
</span>{" "}
|
||||||
|
files
|
||||||
|
</dd>
|
||||||
|
<dd className="is-size-4">
|
||||||
|
Library size
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{" "}
|
||||||
|
{props.stats.comicDirectorySize &&
|
||||||
|
prettyBytes(props.stats.comicDirectorySize)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(props.stats.statistics[0].issues) && (
|
||||||
|
<dd className="is-size-6">
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{props.stats.statistics[0].issues.length}
|
||||||
|
</span>{" "}
|
||||||
|
tagged with ComicVine
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
|
||||||
|
<dd className="is-size-6">
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{props.stats.statistics[0].issuesWithComicInfoXML.length}
|
||||||
|
</span>{" "}
|
||||||
|
with
|
||||||
|
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||||
|
ComicInfo.xml
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 column is-one-quarter">
|
||||||
|
<dl className="box">
|
||||||
|
<dd className="is-size-6">
|
||||||
|
<span className="has-text-weight-bold"></span> Issues
|
||||||
|
</dd>
|
||||||
|
<dd className="is-size-6">
|
||||||
|
<span className="has-text-weight-bold">304</span> Volumes
|
||||||
|
</dd>
|
||||||
|
<dd className="is-size-6">
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||||
|
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||||
|
return (
|
||||||
|
<span key={idx}>
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{fileType.data.length}
|
||||||
|
</span>
|
||||||
|
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||||
|
{fileType._id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* file types */}
|
||||||
|
<div className="p-3 column is-two-fifths">
|
||||||
|
{/* publisher with most issues */}
|
||||||
|
<dl className="box">
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(
|
||||||
|
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
|
||||||
|
) && (
|
||||||
|
<dd className="is-size-6">
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{
|
||||||
|
props.stats.statistics[0]
|
||||||
|
.publisherWithMostComicsInLibrary[0]._id
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{" has the most issues "}
|
||||||
|
<span className="has-text-weight-bold">
|
||||||
|
{
|
||||||
|
props.stats.statistics[0]
|
||||||
|
.publisherWithMostComicsInLibrary[0].count
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
|
<dd className="is-size-6">
|
||||||
|
<span className="has-text-weight-bold">304</span> Volumes
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryStatistics;
|
||||||
164
src/client/components/Dashboard/PullList.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { isNil, map } from "lodash";
|
||||||
|
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||||
|
import { importToDB } from "../../actions/fileops.actions";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import Slider from "react-slick";
|
||||||
|
import "slick-carousel/slick/slick.css";
|
||||||
|
import "slick-carousel/slick/slick-theme.css";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
type PullListProps = {
|
||||||
|
issues: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PullList = ({ issues }: PullListProps): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(
|
||||||
|
getWeeklyPullList({
|
||||||
|
startDate: "2023-5-25",
|
||||||
|
pageSize: "15",
|
||||||
|
currentPage: "1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
(sourceName: string, locgMetadata) =>
|
||||||
|
dispatch(importToDB(sourceName, { locg: locgMetadata })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
/*
|
||||||
|
const foo = {
|
||||||
|
coverFile: {}, // pointer to which cover file to use
|
||||||
|
rawFileDetails: {}, // #1
|
||||||
|
sourcedMetadata: {
|
||||||
|
comicInfo: {},
|
||||||
|
comicvine: {}, // #2
|
||||||
|
locg: {}, // #2
|
||||||
|
},
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
|
||||||
|
let sliderRef = createRef();
|
||||||
|
const settings = {
|
||||||
|
dots: false,
|
||||||
|
infinite: false,
|
||||||
|
speed: 500,
|
||||||
|
slidesToShow: 5,
|
||||||
|
slidesToScroll: 5,
|
||||||
|
initialSlide: 0,
|
||||||
|
responsive: [
|
||||||
|
{
|
||||||
|
breakpoint: 1024,
|
||||||
|
settings: {
|
||||||
|
slidesToShow: 3,
|
||||||
|
slidesToScroll: 3,
|
||||||
|
infinite: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: 600,
|
||||||
|
settings: {
|
||||||
|
slidesToShow: 2,
|
||||||
|
slidesToScroll: 2,
|
||||||
|
initialSlide: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: 480,
|
||||||
|
settings: {
|
||||||
|
slidesToShow: 1,
|
||||||
|
slidesToScroll: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
sliderRef.slickNext();
|
||||||
|
};
|
||||||
|
const previous = () => {
|
||||||
|
sliderRef.slickPrev();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content">
|
||||||
|
<h4 className="title is-4">
|
||||||
|
<i className="fa-solid fa-splotch"></i> Discover
|
||||||
|
</h4>
|
||||||
|
<p className="subtitle is-7">
|
||||||
|
Pull List aggregated for the week from League Of Comic Geeks
|
||||||
|
</p>
|
||||||
|
<div className="field is-grouped">
|
||||||
|
{/* select week */}
|
||||||
|
<div className="control">
|
||||||
|
<div className="select is-small">
|
||||||
|
<select>
|
||||||
|
<option>Select Week</option>
|
||||||
|
<option>With options</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* See all pull list issues */}
|
||||||
|
<div className="control">
|
||||||
|
<Link to={"/pull-list/all/"}>
|
||||||
|
<button className="button is-small">View all issues</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="field has-addons">
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-rounded is-small" onClick={previous}>
|
||||||
|
<i className="fa-solid fa-caret-left"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-rounded is-small" onClick={next}>
|
||||||
|
<i className="fa-solid fa-caret-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider {...settings} ref={(c) => (sliderRef = c)}>
|
||||||
|
{!isNil(pullList) &&
|
||||||
|
pullList &&
|
||||||
|
map(pullList, ({issue}, idx) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={idx}
|
||||||
|
orientation={"vertical"}
|
||||||
|
imageUrl={issue.cover}
|
||||||
|
hasDetails
|
||||||
|
title={ellipsize(issue.name, 18)}
|
||||||
|
cardContainerStyle={{
|
||||||
|
marginRight: 22,
|
||||||
|
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="content">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tag">{issue.publisher}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
className="button is-small is-success is-outlined is-light"
|
||||||
|
onClick={() => addToLibrary("locg", issue)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus"></i> Want
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Slider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PullList;
|
||||||
146
src/client/components/Dashboard/RecentlyImported.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import {
|
||||||
|
determineCoverFile,
|
||||||
|
determineExternalMetadata,
|
||||||
|
} from "../../shared/utils/metadata.utils";
|
||||||
|
|
||||||
|
type RecentlyImportedProps = {
|
||||||
|
comicBookCovers: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecentlyImported = ({
|
||||||
|
comicBookCovers,
|
||||||
|
}: RecentlyImportedProps): ReactElement => {
|
||||||
|
const breakpointColumnsObj = {
|
||||||
|
default: 5,
|
||||||
|
1100: 4,
|
||||||
|
700: 2,
|
||||||
|
600: 2,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content mt-5">
|
||||||
|
<h4 className="title is-4">
|
||||||
|
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
|
||||||
|
</h4>
|
||||||
|
<p className="subtitle is-7">
|
||||||
|
Recent Library activity such as imports, tagging, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Masonry
|
||||||
|
breakpointCols={breakpointColumnsObj}
|
||||||
|
className="recent-comics-container"
|
||||||
|
columnClassName="recent-comics-column"
|
||||||
|
>
|
||||||
|
{map(
|
||||||
|
comicBookCovers,
|
||||||
|
(
|
||||||
|
{
|
||||||
|
_id,
|
||||||
|
rawFileDetails,
|
||||||
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
|
acquisition: {
|
||||||
|
source: { name },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
idx,
|
||||||
|
) => {
|
||||||
|
const { issueName, url } = determineCoverFile({
|
||||||
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
});
|
||||||
|
const { issue, coverURL, icon } = determineExternalMetadata(name, {
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
});
|
||||||
|
const isComicBookMetadataAvailable =
|
||||||
|
!isUndefined(comicvine) &&
|
||||||
|
!isUndefined(comicvine.volumeInformation);
|
||||||
|
|
||||||
|
const titleElement = (
|
||||||
|
<Link to={"/comic/details/" + _id}>
|
||||||
|
{ellipsize(issueName, 20)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={_id}>
|
||||||
|
<Card
|
||||||
|
orientation={"vertical"}
|
||||||
|
imageUrl={url}
|
||||||
|
hasDetails
|
||||||
|
title={issueName ? titleElement : null}
|
||||||
|
>
|
||||||
|
<div className="content is-flex is-flex-direction-row">
|
||||||
|
{/* Raw file presence */}
|
||||||
|
{isNil(rawFileDetails) && (
|
||||||
|
<span className="icon custom-icon is-small has-text-danger mr-2">
|
||||||
|
<img src="/src/client/assets/img/missing-file.svg" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* ComicInfo.xml presence */}
|
||||||
|
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||||
|
<span className="icon custom-icon is-small has-text-danger">
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/comicinfoxml.svg"
|
||||||
|
alt={"ComicInfo.xml file detected."}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* ComicVine metadata presence */}
|
||||||
|
{isComicBookMetadataAvailable && (
|
||||||
|
<span className="icon custom-icon">
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
|
alt={"ComicVine metadata detected."}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Issue type */}
|
||||||
|
{isComicBookMetadataAvailable &&
|
||||||
|
!isNil(
|
||||||
|
detectIssueTypes(comicvine.volumeInformation.description),
|
||||||
|
) ? (
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{
|
||||||
|
detectIssueTypes(
|
||||||
|
comicvine.volumeInformation.description,
|
||||||
|
).displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{/* metadata card */}
|
||||||
|
{!isNil(name) && (
|
||||||
|
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
|
||||||
|
<dd className="is-size-9">
|
||||||
|
<dl>
|
||||||
|
<span className="icon custom-icon">
|
||||||
|
<img src={`/src/client/assets/img/${icon}`} />
|
||||||
|
</span>
|
||||||
|
</dl>
|
||||||
|
<dl>
|
||||||
|
<span className="small-tag">
|
||||||
|
{ellipsize(issue, 15)}
|
||||||
|
</span>
|
||||||
|
</dl>
|
||||||
|
</dd>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Masonry>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
src/client/components/Dashboard/VolumeGroups.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { map, unionBy } from "lodash";
|
||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
|
||||||
|
export const VolumeGroups = (props): ReactElement => {
|
||||||
|
const breakpointColumnsObj = {
|
||||||
|
default: 5,
|
||||||
|
1100: 4,
|
||||||
|
700: 2,
|
||||||
|
500: 1,
|
||||||
|
};
|
||||||
|
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||||
|
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const navigateToVolumes = (row) => {
|
||||||
|
navigate(`/volumes/all`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="volumes-container mt-4">
|
||||||
|
<div className="content">
|
||||||
|
<a className="mb-1" onClick={navigateToVolumes}>
|
||||||
|
<span className="is-size-4 has-text-weight-semibold">
|
||||||
|
<i className="fa-solid fa-layer-group"></i> Volumes
|
||||||
|
</span>
|
||||||
|
<span className="icon mt-1">
|
||||||
|
<i className="fa-solid fa-angle-right"></i>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className="subtitle is-7">Based on ComicVine Volume information</p>
|
||||||
|
</div>
|
||||||
|
<Masonry
|
||||||
|
breakpointCols={breakpointColumnsObj}
|
||||||
|
className="volumes-grid"
|
||||||
|
columnClassName="volumes-grid-column"
|
||||||
|
>
|
||||||
|
{map(deduplicatedGroups, (data) => {
|
||||||
|
return (
|
||||||
|
<div className="stack" key={data._id}>
|
||||||
|
<img src={data.volumes.image.small_url} />
|
||||||
|
<div className="content">
|
||||||
|
<div className="stack-title is-size-8">
|
||||||
|
<Link to={`/volume/details/${data._id}`}>
|
||||||
|
{ellipsize(data.volumes.name, 18)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons">
|
||||||
|
<span className="tag is-primary is-light">Issues</span>
|
||||||
|
<span className="tag">{data.volumes.count_of_issues}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Masonry>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeGroups;
|
||||||
114
src/client/components/Dashboard/WantedComicsList.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
|
||||||
|
type WantedComicsListProps = {
|
||||||
|
comics: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WantedComicsList = ({
|
||||||
|
comics,
|
||||||
|
}: WantedComicsListProps): ReactElement => {
|
||||||
|
const breakpointColumnsObj = {
|
||||||
|
default: 5,
|
||||||
|
1100: 4,
|
||||||
|
700: 2,
|
||||||
|
500: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const navigateToWantedComics = (row) => {
|
||||||
|
navigate(`/wanted/all`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content mt-6">
|
||||||
|
<a className="mb-1" onClick={navigateToWantedComics}>
|
||||||
|
<span className="is-size-4 has-text-weight-semibold">
|
||||||
|
<i className="fa-solid fa-asterisk"></i> Wanted Comics
|
||||||
|
</span>
|
||||||
|
<span className="icon mt-1">
|
||||||
|
<i className="fa-solid fa-angle-right"></i>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className="subtitle is-7">
|
||||||
|
Comics marked as wanted from various sources.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Masonry
|
||||||
|
breakpointCols={breakpointColumnsObj}
|
||||||
|
className="recent-comics-container"
|
||||||
|
columnClassName="recent-comics-column"
|
||||||
|
>
|
||||||
|
{map(
|
||||||
|
comics,
|
||||||
|
({
|
||||||
|
_id,
|
||||||
|
rawFileDetails,
|
||||||
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
|
}) => {
|
||||||
|
const isComicBookMetadataAvailable =
|
||||||
|
!isUndefined(comicvine) &&
|
||||||
|
!isUndefined(comicvine.volumeInformation);
|
||||||
|
const consolidatedComicMetadata = {
|
||||||
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { issueName, url } = determineCoverFile(
|
||||||
|
consolidatedComicMetadata,
|
||||||
|
);
|
||||||
|
const titleElement = (
|
||||||
|
<Link to={"/comic/details/" + _id}>
|
||||||
|
{ellipsize(issueName, 20)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={_id}
|
||||||
|
orientation={"vertical"}
|
||||||
|
imageUrl={url}
|
||||||
|
hasDetails
|
||||||
|
title={issueName ? titleElement : <span>No Name</span>}
|
||||||
|
>
|
||||||
|
<div className="content is-flex is-flex-direction-row">
|
||||||
|
{/* comicVine metadata presence */}
|
||||||
|
{isComicBookMetadataAvailable && (
|
||||||
|
<span className="icon custom-icon">
|
||||||
|
<img src="/src/client/assets/img/cvlogo.svg" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isEmpty(locg) && (
|
||||||
|
<span className="icon custom-icon">
|
||||||
|
<img src="/src/client/assets/img/locglogo.svg" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Issue type */}
|
||||||
|
{isComicBookMetadataAvailable &&
|
||||||
|
!isNil(
|
||||||
|
detectIssueTypes(comicvine.volumeInformation.description),
|
||||||
|
) ? (
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{
|
||||||
|
detectIssueTypes(
|
||||||
|
comicvine.volumeInformation.description,
|
||||||
|
).displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Masonry>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
interface ZeroStateProps {
|
interface ZeroStateProps {
|
||||||
header: string;
|
header: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<article className="message is-info">
|
<article className="message is-info">
|
||||||
<div className="message-body">
|
<div className="message-body">
|
||||||
<p>{ props.header }</p>
|
<p>{props.header}</p>
|
||||||
{ props.message }
|
{props.message}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ZeroState;
|
export default ZeroState;
|
||||||
94
src/client/components/DnD.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { ReactElement, useState } from "react";
|
||||||
|
// https://codesandbox.io/s/dndkit-sortable-image-grid-py6ve?file=/src/Grid.jsx
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
DragOverlay,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
rectSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
import { Grid } from "./Grid";
|
||||||
|
import { SortableCover } from "./SortableCover";
|
||||||
|
import { Cover } from "./Cover";
|
||||||
|
import { map } from "lodash";
|
||||||
|
|
||||||
|
export const DnD = (data) => {
|
||||||
|
const [items, setItems] = useState(data.data);
|
||||||
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
|
||||||
|
|
||||||
|
function handleDragStart(event) {
|
||||||
|
setActiveId(event.active.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event) {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (active.id !== over.id) {
|
||||||
|
setItems((items) => {
|
||||||
|
const oldIndex = items.indexOf(active.id);
|
||||||
|
const newIndex = items.indexOf(over.id);
|
||||||
|
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setActiveId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragCancel() {
|
||||||
|
setActiveId(null);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||||
|
<Grid columns={4}>
|
||||||
|
{map(items, (url, index) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SortableCover key={url} url={url} index={index} />
|
||||||
|
<div
|
||||||
|
className="mt-2 mb-2"
|
||||||
|
onClick={(e) => data.onClickHandler(url)}
|
||||||
|
>
|
||||||
|
<div className="box p-2 pl-3 control-palette">
|
||||||
|
<span className="tag is-warning mr-2">{index}</span>
|
||||||
|
<span className="icon is-small mr-2">
|
||||||
|
<i className="fa-solid fa-vial"></i>
|
||||||
|
</span>
|
||||||
|
<span className="icon is-small mr-2">
|
||||||
|
<i className="fa-solid fa-bullseye"></i>
|
||||||
|
</span>
|
||||||
|
<span className="icon is-small mr-2">
|
||||||
|
<i className="fa-regular fa-trash-can"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay adjustScale={true}>
|
||||||
|
{activeId ? (
|
||||||
|
<Cover url={activeId} index={items.indexOf(activeId)} />
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DnD;
|
||||||
100
src/client/components/Downloads/Downloads.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { ReactElement, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { getTransfers } from "../../actions/airdcpp.actions";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||||
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
|
|
||||||
|
interface IDownloadsProps {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Downloads = (props: IDownloadsProps): ReactElement => {
|
||||||
|
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||||
|
const {
|
||||||
|
airDCPPState: { settings, socket },
|
||||||
|
} = airDCPPConfiguration;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const airDCPPTransfers = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.transfers,
|
||||||
|
);
|
||||||
|
const issueBundles = useSelector((state: RootState) => state.airdcpp.issue_bundles);
|
||||||
|
const [bundles, setBundles] = useState([]);
|
||||||
|
// Make the call to get all transfers from AirDC++
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUndefined(socket) && !isEmpty(settings)) {
|
||||||
|
dispatch(
|
||||||
|
getTransfers(socket, {
|
||||||
|
username: `${settings.directConnect.client.host.username}`,
|
||||||
|
password: `${settings.directConnect.client.host.password}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUndefined(issueBundles)) {
|
||||||
|
const foo = issueBundles.data.map((bundle) => {
|
||||||
|
const { rawFileDetails, inferredMetadata, acquisition: { directconnect: { downloads } }, sourcedMetadata: { locg, comicvine } } = bundle;
|
||||||
|
const { issueName, url } = determineCoverFile({
|
||||||
|
rawFileDetails, comicvine, locg,
|
||||||
|
});
|
||||||
|
return { ...bundle, issueName, url }
|
||||||
|
|
||||||
|
})
|
||||||
|
setBundles(foo);
|
||||||
|
}
|
||||||
|
}, [issueBundles])
|
||||||
|
|
||||||
|
return !isNil(bundles) ?
|
||||||
|
<div className="container">
|
||||||
|
<section className="section">
|
||||||
|
<h1 className="title">Downloads</h1>
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-half">
|
||||||
|
{bundles.map((bundle, idx) => {
|
||||||
|
console.log(bundle);
|
||||||
|
return <div key={idx}>
|
||||||
|
<MetadataPanel
|
||||||
|
data={bundle}
|
||||||
|
imageStyle={{ maxWidth: 80 }}
|
||||||
|
titleStyle={{ fontSize: "0.8rem" }}
|
||||||
|
tagsStyle={{ fontSize: "0.7rem" }}
|
||||||
|
containerStyle={{
|
||||||
|
maxWidth: 400,
|
||||||
|
padding: 0,
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<table className="table is-size-7">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Bundle ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bundle.acquisition.directconnect.downloads.map((bundle, idx) => {
|
||||||
|
return (<tr key={idx}>
|
||||||
|
<td>{bundle.name}</td>
|
||||||
|
<td>{bundle.size}</td>
|
||||||
|
<td>{bundle.type.str}</td>
|
||||||
|
<td><span className="tag is-warning">{bundle.bundleId}</span></td>
|
||||||
|
</tr>)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div> : <div>There are no downloads.</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Downloads;
|
||||||
82
src/client/components/GlobalSearchBar/SearchBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { debounce, isEmpty, map } from "lodash";
|
||||||
|
import React, { ReactElement, useCallback, useState } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import Card from "../Carda";
|
||||||
|
|
||||||
|
import { searchIssue } from "../../actions/fileops.actions";
|
||||||
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
|
|
||||||
|
interface ISearchBarProps {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const searchResults = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.librarySearchResultsFormatted,
|
||||||
|
);
|
||||||
|
|
||||||
|
const performSearch = useCallback(
|
||||||
|
debounce((e) => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
volumeName: e.target.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "volumeName",
|
||||||
|
trigger: "globalSearchBar",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 500),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="control has-icons-right">
|
||||||
|
<input
|
||||||
|
className="input mt-2"
|
||||||
|
placeholder="Search Library"
|
||||||
|
onChange={(e) => performSearch(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="icon is-right mt-2">
|
||||||
|
<i className="fa-solid fa-magnifying-glass"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isEmpty(searchResults) ? (
|
||||||
|
<div
|
||||||
|
className="columns box is-multiline"
|
||||||
|
style={{
|
||||||
|
padding: 4,
|
||||||
|
position: "absolute",
|
||||||
|
width: 360,
|
||||||
|
margin: "60px 0 0 350px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{map(searchResults, (result, idx) => (
|
||||||
|
<MetadataPanel
|
||||||
|
data={result}
|
||||||
|
key={idx}
|
||||||
|
imageStyle={{ maxWidth: 70 }}
|
||||||
|
titleStyle={{ fontSize: "0.8rem" }}
|
||||||
|
tagsStyle={{ fontSize: "0.7rem" }}
|
||||||
|
containerStyle={{
|
||||||
|
width: "100vw",
|
||||||
|
padding: 0,
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
src/client/components/Grid.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function Grid({ children, columns }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 200px)`,
|
||||||
|
columnGap: 1,
|
||||||
|
gridGap: 10,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,168 +1,133 @@
|
|||||||
import * as React from "react";
|
import React, { ReactElement, useCallback, useContext, useState } from "react";
|
||||||
import { isUndefined } from "lodash";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { connect } from "react-redux";
|
import {
|
||||||
import { fetchComicBookMetadata } from "../actions/fileops.actions";
|
fetchComicBookMetadata,
|
||||||
import { IFolderData } from "threetwo-ui-typings";
|
toggleImportQueueStatus,
|
||||||
import { io, Socket } from "socket.io-client";
|
} from "../actions/fileops.actions";
|
||||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||||
import DynamicList, { createCache } from "react-window-dynamic-list";
|
import Loader from "react-loader-spinner";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
matches: unknown;
|
matches?: unknown;
|
||||||
fetchComicMetadata: any;
|
fetchComicMetadata?: any;
|
||||||
path: string;
|
path: string;
|
||||||
covers: any;
|
covers?: any;
|
||||||
}
|
}
|
||||||
interface IState {
|
|
||||||
folderWalkResults?: Array<IFolderData>;
|
|
||||||
searchPaneIndex: number;
|
|
||||||
fileOps: any;
|
|
||||||
}
|
|
||||||
let socket: Socket;
|
|
||||||
class Import extends React.Component<IProps, IState> {
|
|
||||||
/**
|
|
||||||
* Returns the average of two numbers.
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* This method is part of the {@link core-library#Statistics | Statistics subsystem}.
|
|
||||||
*
|
|
||||||
* @param x - The first input number
|
|
||||||
* @param y - The second input number
|
|
||||||
* @returns The arithmetic mean of `x` and `y`
|
|
||||||
*
|
|
||||||
* @beta
|
|
||||||
*/
|
|
||||||
constructor(props: IProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
folderWalkResults: [],
|
|
||||||
searchPaneIndex: 0,
|
|
||||||
fileOps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleSearchResultsPane(paneId: number): void {
|
/**
|
||||||
this.setState({
|
* Returns the average of two numbers.
|
||||||
searchPaneIndex: paneId,
|
*
|
||||||
});
|
* @remarks
|
||||||
}
|
* This method is part of the {@link core-library#Statistics | Statistics subsystem}.
|
||||||
|
*
|
||||||
|
* @param x - The first input number
|
||||||
|
* @param y - The second input number
|
||||||
|
* @returns The arithmetic mean of `x` and `y`
|
||||||
|
*
|
||||||
|
* @beta
|
||||||
|
*/
|
||||||
|
|
||||||
public initiateSocketConnection = () => {
|
export const Import = (props: IProps): ReactElement => {
|
||||||
if (typeof this.props.path !== "undefined") {
|
const dispatch = useDispatch();
|
||||||
socket = io(SOCKET_BASE_URI, {
|
const libraryQueueResults = useSelector(
|
||||||
reconnectionDelayMax: 10000,
|
(state: RootState) => state.fileOps.librarySearchResultCount,
|
||||||
});
|
);
|
||||||
|
const libraryQueueImportStatus = useSelector(
|
||||||
socket.on("connect", () => {
|
(state: RootState) => state.fileOps.IMSCallInProgress,
|
||||||
console.log(`connect ${socket.id}`);
|
);
|
||||||
});
|
const [isImportQueuePaused, setImportQueueStatus] = useState(false);
|
||||||
this.props.fetchComicMetadata();
|
const initiateImport = useCallback(() => {
|
||||||
|
if (typeof props.path !== "undefined") {
|
||||||
|
dispatch(fetchComicBookMetadata(props.path));
|
||||||
}
|
}
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
public cache = createCache();
|
const toggleImport = useCallback(() => {
|
||||||
|
setImportQueueStatus(!isImportQueuePaused);
|
||||||
|
if (isImportQueuePaused === false) {
|
||||||
|
dispatch(toggleImportQueueStatus({ action: "resume" }));
|
||||||
|
} else if (isImportQueuePaused === true) {
|
||||||
|
dispatch(toggleImportQueueStatus({ action: "pause" }));
|
||||||
|
}
|
||||||
|
}, [isImportQueuePaused]);
|
||||||
|
const pauseIconText = (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-pause mr-2"></i> Pause Import
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const playIconText = (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-play mr-2"></i> Resume Import
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<section className="section is-small">
|
||||||
|
<h1 className="title">Import</h1>
|
||||||
|
<article className="message is-dark">
|
||||||
|
<div className="message-body">
|
||||||
|
<p className="mb-2">
|
||||||
|
<span className="tag is-medium is-info is-light">
|
||||||
|
Import Only
|
||||||
|
</span>
|
||||||
|
will add comics identified from the mapped folder into the local
|
||||||
|
db.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="tag is-medium is-info is-light">
|
||||||
|
Import and Tag
|
||||||
|
</span>
|
||||||
|
will scan the ComicVine, shortboxed APIs and import comics from
|
||||||
|
the mapped folder with the additional metadata.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<p className="buttons">
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
libraryQueueImportStatus
|
||||||
|
? "button is-loading is-medium"
|
||||||
|
: "button is-medium"
|
||||||
|
}
|
||||||
|
onClick={initiateImport}
|
||||||
|
>
|
||||||
|
<span className="icon">
|
||||||
|
<i className="fas fa-file-import"></i>
|
||||||
|
</span>
|
||||||
|
<span>Import Only</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
public renderRow = ({ index, style }) => (
|
<button className="button is-medium">
|
||||||
<div style={style} className="min is-size-7">
|
<span className="icon">
|
||||||
<span className="tag is-light">{index}</span>
|
<i className="fas fa-tag"></i>
|
||||||
<div className="tags has-addons">
|
</span>
|
||||||
<span className="tag is-success">cover</span>
|
<span>Import and Tag</span>
|
||||||
<span className="tag is-success is-light has-text-weight-medium">
|
</button>
|
||||||
{this.props.covers[index].comicBookCoverMetadata.name}
|
</p>
|
||||||
</span>
|
<div className="columns is-multiline">
|
||||||
</div>
|
<div className="column is-one-fifth">
|
||||||
imported from
|
<div className="box control-palette">
|
||||||
<div className="tags has-addons">
|
<span className="is-size-2 has-text-weight-bold">
|
||||||
<span className="tag is-success">path</span>
|
{JSON.stringify(libraryQueueResults, null, 2)}
|
||||||
<span className="tag is-success is-light has-text-weight-medium">
|
</span>
|
||||||
{this.props.covers[index].comicBookCoverMetadata.path}
|
</div>
|
||||||
</span>
|
<div className="is-half">
|
||||||
</div>
|
<div className="content">
|
||||||
<div className="db-import-result-panel">
|
<div className="control">
|
||||||
<pre className="has-background-success-light">
|
<button
|
||||||
<span className="icon">
|
className="button is-warning is-light"
|
||||||
<i className="fas fa-database"></i>
|
onClick={toggleImport}
|
||||||
</span>
|
>
|
||||||
{JSON.stringify(this.props.covers[index].dbImportResult, null, 2)}
|
{!isImportQueuePaused ? pauseIconText : playIconText}
|
||||||
</pre>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
public render() {
|
export default Import;
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<section className="section is-small">
|
|
||||||
<h1 className="title">Import</h1>
|
|
||||||
|
|
||||||
<article className="message is-dark">
|
|
||||||
<div className="message-body">
|
|
||||||
<p className="mb-2">
|
|
||||||
<span className="tag is-medium is-info is-light">
|
|
||||||
Import Only
|
|
||||||
</span>
|
|
||||||
will add comics identified from the mapped folder into the local
|
|
||||||
db.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="tag is-medium is-info is-light">
|
|
||||||
Import and Tag
|
|
||||||
</span>
|
|
||||||
will scan the ComicVine, shortboxed APIs and import comics from
|
|
||||||
the mapped folder with the additional metadata.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<p className="buttons">
|
|
||||||
<button
|
|
||||||
className="button is-medium"
|
|
||||||
onClick={this.initiateSocketConnection}
|
|
||||||
>
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-file-import"></i>
|
|
||||||
</span>
|
|
||||||
<span>Import Only</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="button is-medium">
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-tag"></i>
|
|
||||||
</span>
|
|
||||||
<span>Import and Tag</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!isUndefined(this.state.folderWalkResults) ? (
|
|
||||||
<div>
|
|
||||||
<DynamicList
|
|
||||||
data={this.props.covers}
|
|
||||||
cache={this.cache}
|
|
||||||
height={1000}
|
|
||||||
width={"100%"}
|
|
||||||
>
|
|
||||||
{this.renderRow}
|
|
||||||
</DynamicList>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state: IState) {
|
|
||||||
console.log("state", state);
|
|
||||||
return {
|
|
||||||
// matches: state.comicInfo.searchResults,
|
|
||||||
covers: state.fileOps.comicBookMetadata,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, ownProps) => ({
|
|
||||||
fetchComicMetadata() {
|
|
||||||
dispatch(fetchComicBookMetadata(ownProps.path));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Import);
|
|
||||||
export { socket };
|
|
||||||
|
|||||||
258
src/client/components/Library/Library.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useMemo, ReactElement, useCallback, useEffect } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
|
import T2Table from "../shared/T2Table";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { searchIssue } from "../../actions/fileops.actions";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* <Library />
|
||||||
|
*/
|
||||||
|
export const Library = (): ReactElement => {
|
||||||
|
const searchResults = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.libraryComics,
|
||||||
|
);
|
||||||
|
const searchError = useSelector((state: RootState) => state.fileOps.librarySearchError);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// programatically navigate to comic detail
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const navigateToComicDetail = (row) => {
|
||||||
|
navigate(`/comic/details/${row.original._id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComicInfoXML = (value) => {
|
||||||
|
return value.data ? (
|
||||||
|
<div className="comicvine-metadata">
|
||||||
|
<dl>
|
||||||
|
<span className="tags has-addons is-size-7">
|
||||||
|
<span className="tag">Series</span>
|
||||||
|
<span className="tag is-warning is-light">
|
||||||
|
{ellipsize(value.data.series[0], 25)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dl>
|
||||||
|
<dl>
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons is-size-7 mt-2">
|
||||||
|
<span className="tag">Pages</span>
|
||||||
|
<span className="tag is-info is-light has-text-weight-bold">
|
||||||
|
{value.data.pagecount[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags has-addons is-size-7 mt-2">
|
||||||
|
<span className="tag">Issue</span>
|
||||||
|
{!isNil(value.data.number) && (
|
||||||
|
<span className="tag has-text-weight-bold is-success is-light">
|
||||||
|
{parseInt(value.data.number[0], 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WantedStatus = ({ value }) => {
|
||||||
|
return !value ? <span className="tag is-info is-light">Wanted</span> : null;
|
||||||
|
};
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
header: "Comic Metadata",
|
||||||
|
footer: 1,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "File Details",
|
||||||
|
id: "fileDetails",
|
||||||
|
minWidth: 400,
|
||||||
|
accessorKey: "_source",
|
||||||
|
cell: (info) => {
|
||||||
|
return <MetadataPanel data={info.getValue()} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "ComicInfo.xml",
|
||||||
|
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||||
|
align: "center",
|
||||||
|
minWidth: 250,
|
||||||
|
cell: (info) =>
|
||||||
|
!isEmpty(info.getValue()) ? (
|
||||||
|
<ComicInfoXML data={info.getValue()} />
|
||||||
|
) : (
|
||||||
|
<span className="tag mt-5">No ComicInfo.xml</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Additional Metadata",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "Publisher",
|
||||||
|
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
|
||||||
|
cell: (info) => {
|
||||||
|
return (
|
||||||
|
!isNil(info.getValue()) && (
|
||||||
|
<h6 className="is-size-7 has-text-weight-bold">
|
||||||
|
{info.getValue().publisher.name}
|
||||||
|
</h6>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Something",
|
||||||
|
accessorKey: "_source.acquisition.source.wanted",
|
||||||
|
cell: (info) => {
|
||||||
|
!isUndefined(info.getValue()) ? (
|
||||||
|
<WantedStatus value={info.getValue().toString()} />
|
||||||
|
) : (
|
||||||
|
"Nothing"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination control that fetches the next x (pageSize) items
|
||||||
|
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||||
|
* @param {number} pageIndex
|
||||||
|
* @param {number} pageSize
|
||||||
|
* @returns void
|
||||||
|
*
|
||||||
|
**/
|
||||||
|
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: pageSize,
|
||||||
|
from: pageSize * pageIndex + 1,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination control that fetches the previous x (pageSize) items
|
||||||
|
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||||
|
* @param {number} pageIndex
|
||||||
|
* @param {number} pageSize
|
||||||
|
* @returns void
|
||||||
|
**/
|
||||||
|
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||||
|
let from = 0;
|
||||||
|
if (pageIndex === 2) {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - 17;
|
||||||
|
} else {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - 16;
|
||||||
|
}
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: pageSize,
|
||||||
|
from,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ImportStatus.propTypes = {
|
||||||
|
// value: PropTypes.bool.isRequired,
|
||||||
|
// };
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="section">
|
||||||
|
<div className="header-area">
|
||||||
|
<h1 className="title">Library</h1>
|
||||||
|
</div>
|
||||||
|
{!isEmpty(searchResults) ? (
|
||||||
|
<div>
|
||||||
|
<div className="library">
|
||||||
|
<T2Table
|
||||||
|
totalPages={searchResults.total.value}
|
||||||
|
columns={columns}
|
||||||
|
sourceData={searchResults?.hits}
|
||||||
|
rowClickHandler={navigateToComicDetail}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage,
|
||||||
|
previousPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-two-thirds">
|
||||||
|
<article className="message is-link">
|
||||||
|
<div className="message-body">
|
||||||
|
No comics were found in the library, Elasticsearch reports no
|
||||||
|
indices. Try importing a few comics into the library and come
|
||||||
|
back.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<pre>
|
||||||
|
{!isUndefined(searchError.data) &&
|
||||||
|
JSON.stringify(
|
||||||
|
searchError.data.meta.body.error.root_cause,
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Library;
|
||||||
109
src/client/components/Library/LibraryGrid.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, ReactElement } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import {
|
||||||
|
removeLeadingPeriod,
|
||||||
|
escapePoundSymbol,
|
||||||
|
} from "../../shared/utils/formatting.utils";
|
||||||
|
import { useTable, usePagination } from "react-table";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { getComicBooks } from "../../actions/fileops.actions";
|
||||||
|
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
|
|
||||||
|
interface ILibraryGridProps {}
|
||||||
|
export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
|
||||||
|
const data = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.recentComics.docs,
|
||||||
|
);
|
||||||
|
const pageTotal = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.recentComics.totalDocs,
|
||||||
|
);
|
||||||
|
const breakpointColumnsObj = {
|
||||||
|
default: 5,
|
||||||
|
1100: 4,
|
||||||
|
700: 3,
|
||||||
|
500: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="section">
|
||||||
|
<h1 className="title">Library</h1>
|
||||||
|
|
||||||
|
<Masonry
|
||||||
|
breakpointCols={breakpointColumnsObj}
|
||||||
|
className="my-masonry-grid"
|
||||||
|
columnClassName="my-masonry-grid_column"
|
||||||
|
>
|
||||||
|
{data.map(({ _id, rawFileDetails, sourcedMetadata }) => {
|
||||||
|
let imagePath = "";
|
||||||
|
let comicName = "";
|
||||||
|
if (!isEmpty(rawFileDetails.cover)) {
|
||||||
|
const encodedFilePath = encodeURI(
|
||||||
|
`${LIBRARY_SERVICE_HOST}/${removeLeadingPeriod(
|
||||||
|
rawFileDetails.cover.filePath,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
imagePath = escapePoundSymbol(encodedFilePath);
|
||||||
|
comicName = rawFileDetails.name;
|
||||||
|
} else if (!isNil(sourcedMetadata)) {
|
||||||
|
imagePath = sourcedMetadata.comicvine.image.small_url;
|
||||||
|
comicName = sourcedMetadata.comicvine.name;
|
||||||
|
}
|
||||||
|
const titleElement = (
|
||||||
|
<Link to={"/comic/details/" + _id}>
|
||||||
|
{ellipsize(comicName, 18)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={_id}
|
||||||
|
orientation={"vertical"}
|
||||||
|
imageUrl={imagePath}
|
||||||
|
hasDetails
|
||||||
|
title={comicName ? titleElement : null}
|
||||||
|
>
|
||||||
|
<div className="content is-flex is-flex-direction-row">
|
||||||
|
{!isEmpty(sourcedMetadata.comicvine) && (
|
||||||
|
<span className="icon cv-icon is-small">
|
||||||
|
<img src="/src/client/assets/img/cvlogo.svg" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isNil(rawFileDetails) && (
|
||||||
|
<span className="icon has-text-info">
|
||||||
|
<i className="fas fa-adjust" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isUndefined(sourcedMetadata.comicvine.volumeInformation) &&
|
||||||
|
!isEmpty(
|
||||||
|
detectIssueTypes(
|
||||||
|
sourcedMetadata.comicvine.volumeInformation.description,
|
||||||
|
),
|
||||||
|
) ? (
|
||||||
|
<span className="tag is-warning ml-1">
|
||||||
|
{
|
||||||
|
detectIssueTypes(
|
||||||
|
sourcedMetadata.comicvine.volumeInformation
|
||||||
|
.description,
|
||||||
|
).displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Masonry>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryGrid;
|
||||||
64
src/client/components/Library/SearchBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { ReactElement, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import { searchIssue } from "../../actions/fileops.actions";
|
||||||
|
|
||||||
|
export const SearchBar = (): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const handleSubmit = useCallback((e) => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
volumeName: e.search,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "volumeName",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="box">
|
||||||
|
<Form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initialValues={{}}
|
||||||
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="field is-grouped">
|
||||||
|
<div className="control search is-expanded">
|
||||||
|
<Field name="search">
|
||||||
|
{({ input, meta }) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
className="input main-search-bar is-medium"
|
||||||
|
placeholder="Type an issue/volume name"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-medium" type="submit">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
||||||
66
src/client/components/Library/TabulatedContentContainer.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import PullList from "../PullList/PullList";
|
||||||
|
import { Volumes } from "../Volumes/Volumes";
|
||||||
|
import WantedComics from "../WantedComics/WantedComics";
|
||||||
|
import { Library } from "./Library";
|
||||||
|
|
||||||
|
interface ITabulatedContentContainerProps {
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Component to draw the contents of a category in a table.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* return (
|
||||||
|
* <TabulatedContentContainer category={"library"} />
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TabulatedContentContainer = (
|
||||||
|
props: ITabulatedContentContainerProps,
|
||||||
|
): ReactElement => {
|
||||||
|
const { category } = props;
|
||||||
|
const renderTabulatedContent = () => {
|
||||||
|
switch (category) {
|
||||||
|
case "library":
|
||||||
|
return <Library />;
|
||||||
|
case "pullList":
|
||||||
|
return <PullList />;
|
||||||
|
case "wanted":
|
||||||
|
return <WantedComics />;
|
||||||
|
case "volumes":
|
||||||
|
return <Volumes />;
|
||||||
|
default:
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderTabulatedContent();
|
||||||
|
// : (
|
||||||
|
// <div className="container">
|
||||||
|
// <section className="section is-small">
|
||||||
|
// <div className="columns">
|
||||||
|
// <div className="column is-two-thirds">
|
||||||
|
// <article className="message is-link">
|
||||||
|
// <div className="message-body">
|
||||||
|
// No comics were found in the library, and Elasticsearch doesn't have any
|
||||||
|
// indices. Try resetting the library from <code>Settings > Flush DB & Temporary Folders</code> and then import your library again.
|
||||||
|
// </div>
|
||||||
|
// </article>
|
||||||
|
// <pre>
|
||||||
|
// {!isUndefined(searchError.data) &&
|
||||||
|
// JSON.stringify(
|
||||||
|
// searchError.data.meta.body.error.root_cause,
|
||||||
|
// null,
|
||||||
|
// 4,
|
||||||
|
// )}
|
||||||
|
// </pre>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </section>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabulatedContentContainer;
|
||||||
@@ -1,57 +1,124 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { map } from "lodash";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { isNil, map } from "lodash";
|
||||||
|
import { applyComicVineMatch } from "../actions/comicinfo.actions";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
matchData: any;
|
matchData: any;
|
||||||
|
comicObjectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBrokenImage = (e) => {
|
||||||
|
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
|
||||||
|
};
|
||||||
|
|
||||||
export const MatchResult = (props: MatchResultProps) => {
|
export const MatchResult = (props: MatchResultProps) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const applyCVMatch = useCallback(
|
||||||
|
(match, comicObjectId) => {
|
||||||
|
dispatch(applyComicVineMatch(match, comicObjectId));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<table>
|
{map(props.matchData, (match, idx) => {
|
||||||
<thead>
|
let issueDescription = "";
|
||||||
<tr>
|
if (!isNil(match.description)) {
|
||||||
<th></th>
|
issueDescription = convert(match.description, {
|
||||||
</tr>
|
baseElements: {
|
||||||
</thead>
|
selectors: ["p"],
|
||||||
<tbody>
|
},
|
||||||
{map(props.matchData, (match, idx) => {
|
});
|
||||||
return (
|
}
|
||||||
<tr className="search-result" key={idx}>
|
return (
|
||||||
<td>
|
<div className="search-result mb-4" key={idx}>
|
||||||
<img className="cover-image" src={match.image.thumb_url} />
|
<div className="columns">
|
||||||
</td>
|
<div className="column is-one-fifth">
|
||||||
<td className="search-result-details">
|
<img
|
||||||
<div className="tag score is-primary is-medium is-pulled-right">
|
className="cover-image"
|
||||||
{parseFloat(match.score).toFixed(2)}
|
src={match.image.thumb_url}
|
||||||
</div>
|
onError={handleBrokenImage}
|
||||||
<div className="is-size-5">{match.name}</div>
|
/>
|
||||||
<div className="is-size-6">{match.volume.name}</div>
|
</div>
|
||||||
|
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
<div className="search-result-details column">
|
||||||
<div className="control">
|
<div className="is-size-5">{match.name}</div>
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Number</span>
|
<div className="field is-grouped is-grouped-multiline mt-1">
|
||||||
<span className="tag is-primary">
|
<div className="control">
|
||||||
{match.issue_number}
|
<div className="tags has-addons">
|
||||||
</span>
|
<span className="tag">Number</span>
|
||||||
</div>
|
<span className="tag is-primary">
|
||||||
</div>
|
{match.issue_number}
|
||||||
<div className="control">
|
</span>
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Type</span>
|
|
||||||
<span className="tag is-warning">
|
|
||||||
{match.resource_type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div className="control">
|
||||||
</tr>
|
<div className="tags has-addons">
|
||||||
);
|
<span className="tag">Cover Date</span>
|
||||||
})}
|
<span className="tag is-warning">{match.cover_date}</span>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="is-size-7">
|
||||||
|
{ellipsize(issueDescription, 300)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vertical-line"></div>
|
||||||
|
|
||||||
|
<div className="columns ml-6 volume-information">
|
||||||
|
<div className="column is-one-fifth">
|
||||||
|
<img
|
||||||
|
src={match.volumeInformation.results.image.icon_url}
|
||||||
|
className="cover-image"
|
||||||
|
onError={handleBrokenImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="column">
|
||||||
|
<div className="is-size-6">{match.volume.name}</div>
|
||||||
|
<div className="field is-grouped is-grouped-multiline mt-2">
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">Total Issues</span>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{match.volumeInformation.results.count_of_issues}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">Publisher</span>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{match.volumeInformation.results.publisher.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column">
|
||||||
|
<button
|
||||||
|
className="button is-normal is-outlined is-primary is-light is-pulled-right"
|
||||||
|
onClick={() => applyCVMatch(match, props.comicObjectId)}
|
||||||
|
>
|
||||||
|
<span className="icon is-size-5">
|
||||||
|
<i className="fas fa-clipboard-check"></i>
|
||||||
|
</span>
|
||||||
|
<span>Apply Match</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
import React from "react";
|
import React, { useContext } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { SearchBar } from "./GlobalSearchBar/SearchBar";
|
||||||
|
import { DownloadProgressTick } from "./ComicDetail/DownloadProgressTick";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { RootState } from "threetwo-ui-typings";
|
import { useSelector } from "react-redux";
|
||||||
|
import { isUndefined } from "lodash";
|
||||||
|
import { format, fromUnixTime } from "date-fns";
|
||||||
|
|
||||||
const Navbar: React.FunctionComponent = (props) => {
|
const Navbar: React.FunctionComponent = (props) => {
|
||||||
const socketConnection = useSelector((state: RootState) => state.fileOps);
|
const downloadProgressTick = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.downloadProgressData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const airDCPPSocketConnectionStatus = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
|
||||||
|
);
|
||||||
|
const airDCPPSessionInfo = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.airDCPPSessionInfo,
|
||||||
|
);
|
||||||
|
const socketDisconnectionReason = useSelector(
|
||||||
|
(state: RootState) => state.airdcpp.socketDisconnectionReason,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<nav className="navbar ">
|
<nav className="navbar is-fixed-top">
|
||||||
<div className="navbar-brand">
|
<div className="navbar-brand">
|
||||||
<a className="navbar-item" href="http://bulma.io">
|
<Link to="/" className="navbar-item">
|
||||||
<img
|
<img
|
||||||
src="http://bulma.io/images/bulma-logo.png"
|
src="/src/client/assets/img/threetwo.svg"
|
||||||
alt="Bulma: a modern CSS framework based on Flexbox"
|
alt="ThreeTwo! A comic book curator"
|
||||||
width="112"
|
width="112"
|
||||||
height="28"
|
height="28"
|
||||||
/>
|
/>
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<a className="navbar-item is-hidden-desktop">
|
<a className="navbar-item is-hidden-desktop">
|
||||||
<span className="icon">
|
<span className="icon">
|
||||||
@@ -47,41 +61,87 @@ const Navbar: React.FunctionComponent = (props) => {
|
|||||||
Import
|
Import
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/library" className="navbar-item">
|
||||||
|
Library
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/downloads" className="navbar-item">
|
||||||
|
Downloads
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<SearchBar />
|
||||||
|
|
||||||
|
<Link to="/search" className="navbar-item">
|
||||||
|
Search ComicVine
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="navbar-end">
|
||||||
|
<a className="navbar-item is-hidden-desktop-only"></a>
|
||||||
|
|
||||||
<div className="navbar-item has-dropdown is-hoverable">
|
<div className="navbar-item has-dropdown is-hoverable">
|
||||||
<a
|
<a className="navbar-link is-arrowless">
|
||||||
className="navbar-link is-active"
|
<i className="fa-solid fa-download"></i>
|
||||||
href="/documentation/overview/start/"
|
{downloadProgressTick && <div className="pulsating-circle"></div>}
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</a>
|
</a>
|
||||||
<div className="navbar-dropdown ">
|
{!isUndefined(downloadProgressTick) ? (
|
||||||
<a className="navbar-item " href="/documentation/overview/start/">
|
<div className="navbar-dropdown is-right is-boxed">
|
||||||
Overview
|
<a className="navbar-item">
|
||||||
</a>
|
<DownloadProgressTick data={downloadProgressTick} />
|
||||||
|
</a> </div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{/* AirDC++ socket connection status */}
|
||||||
|
<div className="navbar-item has-dropdown is-hoverable">
|
||||||
|
{airDCPPSocketConnectionStatus ? (
|
||||||
|
<>
|
||||||
|
<a className="navbar-link is-arrowless has-text-success">
|
||||||
|
<i className="fa-solid fa-bolt"></i>
|
||||||
|
</a>
|
||||||
|
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed">
|
||||||
|
{/* AirDC++ Session Information */}
|
||||||
|
|
||||||
<a
|
<p>
|
||||||
className="navbar-item is-active"
|
Last login was{" "}
|
||||||
href="http://bulma.io/documentation/components/breadcrumb/"
|
<span className="tag">
|
||||||
>
|
{format(
|
||||||
Components
|
fromUnixTime(airDCPPSessionInfo.user.last_login),
|
||||||
</a>
|
"dd MMMM, yyyy",
|
||||||
|
)}
|
||||||
<hr className="navbar-divider" />
|
</span>
|
||||||
<div className="navbar-item">
|
</p>
|
||||||
<div>
|
<hr className="navbar-divider" />
|
||||||
<p className="is-size-6-desktop">
|
<p>
|
||||||
<strong className="has-text-info">0.5.1</strong>
|
<span className="tag has-text-success">
|
||||||
|
{airDCPPSessionInfo.user.username}
|
||||||
|
</span>
|
||||||
|
connected to{" "}
|
||||||
|
<span className="tag has-text-success">
|
||||||
|
{airDCPPSessionInfo.system_info.client_version}
|
||||||
|
</span>{" "}
|
||||||
|
with session ID{" "}
|
||||||
|
<span className="tag has-text-success">
|
||||||
|
{airDCPPSessionInfo.session_id}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<small>
|
{/* <pre>{JSON.stringify(airDCPPSessionInfo, null, 2)}</pre> */}
|
||||||
<a className="bd-view-all-versions" href="/versions">
|
|
||||||
View all versions
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<a className="navbar-link is-arrowless has-text-danger">
|
||||||
|
<i className="fa-solid fa-bolt"></i>
|
||||||
|
</a>
|
||||||
|
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(socketDisconnectionReason, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="navbar-item has-dropdown is-hoverable is-mega">
|
<div className="navbar-item has-dropdown is-hoverable is-mega">
|
||||||
<div className="navbar-link flex">Blog</div>
|
<div className="navbar-link flex">Blog</div>
|
||||||
<div id="blogDropdown" className="navbar-dropdown">
|
<div id="blogDropdown" className="navbar-dropdown">
|
||||||
@@ -183,20 +243,13 @@ const Navbar: React.FunctionComponent = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="navbar-end">
|
|
||||||
<a className="navbar-item is-hidden-desktop-only"></a>
|
|
||||||
<div className="navbar-item">
|
<div className="navbar-item">
|
||||||
<div className="field is-grouped">
|
<div className="field is-grouped">
|
||||||
<p className="control">
|
<p className="control">
|
||||||
{socketConnection.socketConnected ? (
|
<Link to="/settings" className="navbar-item">
|
||||||
<span className="icon is-small has-text-success">
|
Settings
|
||||||
<i className="fas fa-plug"></i>
|
</Link>
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="control">Settings</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
129
src/client/components/PullList/PullList.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||||
|
import T2Table from "../shared/T2Table";
|
||||||
|
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
|
export const PullList = (): ReactElement => {
|
||||||
|
const pullListComics = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.pullList,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(
|
||||||
|
getWeeklyPullList({
|
||||||
|
startDate: "2022-11-15",
|
||||||
|
pageSize: "15",
|
||||||
|
currentPage: "1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const nextPageHandler = () => {};
|
||||||
|
const previousPageHandler = () => {};
|
||||||
|
const columnData = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
header: "Comic Information",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "Details",
|
||||||
|
id: "comicDetails",
|
||||||
|
minWidth: 450,
|
||||||
|
accessorKey: "issue",
|
||||||
|
cell: (row) => {
|
||||||
|
const item = row.getValue();
|
||||||
|
return (
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-three-quarters">
|
||||||
|
<div className="comic-detail issue-metadata">
|
||||||
|
<dl>
|
||||||
|
<dd>
|
||||||
|
<div className="columns mt-2">
|
||||||
|
<div className="column is-3">
|
||||||
|
<Card
|
||||||
|
imageUrl={item.cover}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="column">
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<h6 className="name has-text-weight-medium mb-1">
|
||||||
|
{item.name}
|
||||||
|
</h6>
|
||||||
|
</dt>
|
||||||
|
<dd className="is-size-7">
|
||||||
|
published by{" "}
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{item.publisher}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7">
|
||||||
|
<span>
|
||||||
|
{ellipsize(item.description, 190)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7 mt-2">
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags">
|
||||||
|
<span className="tag is-success is-light has-text-weight-semibold">
|
||||||
|
{item.price}
|
||||||
|
</span>
|
||||||
|
<span className="tag is-success is-light">
|
||||||
|
{item.pulls}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="section">
|
||||||
|
<div className="header-area">
|
||||||
|
<h1 className="title">Weekly Pull List</h1>
|
||||||
|
</div>
|
||||||
|
{!isNil(pullListComics) && (
|
||||||
|
<div>
|
||||||
|
<div className="library">
|
||||||
|
<T2Table
|
||||||
|
sourceData={pullListComics}
|
||||||
|
columns={columnData}
|
||||||
|
totalPages={pullListComics.length}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage: nextPageHandler,
|
||||||
|
previousPage: previousPageHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PullList;
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Card from "./Card";
|
|
||||||
import { map } from "lodash";
|
|
||||||
|
|
||||||
type RecentlyImportedProps = {
|
|
||||||
comicBookCovers: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecentlyImported = ({
|
|
||||||
comicBookCovers,
|
|
||||||
}: RecentlyImportedProps) => (
|
|
||||||
<section className="card-container">
|
|
||||||
{map(comicBookCovers.docs, (doc, idx) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={idx}
|
|
||||||
comicBookCoversMetadata={doc.rawFileDetails}
|
|
||||||
mongoObjId={doc._id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
181
src/client/components/Search.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useMemo, useCallback, ReactElement } from "react";
|
||||||
|
import { isNil, isEmpty } from "lodash";
|
||||||
|
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
||||||
|
import { importToDB } from "../actions/fileops.actions";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import { comicinfoAPICall } from "../actions/comicinfo.actions";
|
||||||
|
import { search } from "../services/api/SearchApi";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import Card from "./Carda";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
interface ISearchProps {}
|
||||||
|
|
||||||
|
export const Search = ({}: ISearchProps): ReactElement => {
|
||||||
|
const formData = {
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const getCVSearchResults = useCallback(
|
||||||
|
(searchQuery) => {
|
||||||
|
dispatch(
|
||||||
|
comicinfoAPICall({
|
||||||
|
callURIAction: "search",
|
||||||
|
callMethod: "GET",
|
||||||
|
callParams: {
|
||||||
|
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
|
||||||
|
query: searchQuery.search,
|
||||||
|
format: "json",
|
||||||
|
limit: "10",
|
||||||
|
offset: "0",
|
||||||
|
field_list:
|
||||||
|
"id,name,deck,api_detail_url,image,description,volume,cover_date",
|
||||||
|
resources: "issue",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
(sourceName: string, comicData) =>
|
||||||
|
dispatch(importToDB(sourceName, { comicvine: comicData })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const comicVineSearchResults = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.searchResults,
|
||||||
|
);
|
||||||
|
const createDescriptionMarkup = (html) => {
|
||||||
|
return { __html: html };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="container">
|
||||||
|
<div className="section search">
|
||||||
|
<h1 className="title">Search</h1>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
onSubmit={getCVSearchResults}
|
||||||
|
initialValues={{
|
||||||
|
...formData,
|
||||||
|
}}
|
||||||
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
|
<form onSubmit={handleSubmit} className="form columns search">
|
||||||
|
<div className="column is-three-quarters search">
|
||||||
|
<Field name="search">
|
||||||
|
{({ input, meta }) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
className="input main-search-bar is-large"
|
||||||
|
placeholder="Type an issue/volume name"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="column">
|
||||||
|
<button type="submit" className="button is-medium">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isNil(comicVineSearchResults.results) &&
|
||||||
|
!isEmpty(comicVineSearchResults.results) ? (
|
||||||
|
<div className="columns is-multiline">
|
||||||
|
{comicVineSearchResults.results.map((result) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className="comicvine-metadata column is-10 mb-3"
|
||||||
|
>
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-one-quarter">
|
||||||
|
<Card
|
||||||
|
key={result.id}
|
||||||
|
orientation={"vertical"}
|
||||||
|
imageUrl={result.image.small_url}
|
||||||
|
title={result.name}
|
||||||
|
hasDetails={false}
|
||||||
|
></Card>
|
||||||
|
</div>
|
||||||
|
<div className="column">
|
||||||
|
<div className="is-size-3">
|
||||||
|
{!isEmpty(result.name) ? (
|
||||||
|
result.name
|
||||||
|
) : (
|
||||||
|
<span className="is-size-3">No Name</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field is-grouped mt-1">
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-light">Cover date</span>
|
||||||
|
<span className="tag is-info is-light">
|
||||||
|
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{result.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href={result.api_detail_url}>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
<p>
|
||||||
|
{ellipsize(
|
||||||
|
convert(result.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
320,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button is-success is-light is-outlined mt-2"
|
||||||
|
onClick={() => addToLibrary("comicvine", result)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus mr-2"></i> Want
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<article className="message is-dark is-half">
|
||||||
|
<div className="message-body">
|
||||||
|
<p className="mb-2">
|
||||||
|
<span className="tag is-medium is-info is-light">
|
||||||
|
Search the ComicVine database
|
||||||
|
</span>
|
||||||
|
Search and add issues, series and trade paperbacks to your
|
||||||
|
library. Then, download them using the configured AirDC++ or
|
||||||
|
torrent clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
||||||
103
src/client/components/Settings.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useState, ReactElement } from "react";
|
||||||
|
import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
|
||||||
|
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
|
||||||
|
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
|
||||||
|
import settingsObject from "../constants/settings/settingsMenu.json";
|
||||||
|
import { isUndefined, map } from "lodash";
|
||||||
|
|
||||||
|
interface ISettingsProps {}
|
||||||
|
|
||||||
|
export const Settings = (props: ISettingsProps): ReactElement => {
|
||||||
|
const [active, setActive] = useState("gen-db");
|
||||||
|
const settingsContent = [
|
||||||
|
{
|
||||||
|
id: "adc-hubs",
|
||||||
|
content: <div key="adc-hubs">{<AirDCPPHubsForm />}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "adc-connection",
|
||||||
|
content: (
|
||||||
|
<div key="adc-connection">
|
||||||
|
<AirDCPPSettingsForm />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "flushdb",
|
||||||
|
content: (
|
||||||
|
<div key="flushdb">
|
||||||
|
<SystemSettingsForm />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="columns">
|
||||||
|
<div className="section column is-one-quarter">
|
||||||
|
<h1 className="title">Settings</h1>
|
||||||
|
<aside className="menu">
|
||||||
|
{map(settingsObject, (settingObject, idx) => {
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
<p className="menu-label">{settingObject.category}</p>
|
||||||
|
{/* First level children */}
|
||||||
|
{!isUndefined(settingObject.children) ? (
|
||||||
|
<ul className="menu-list" key={settingObject.id}>
|
||||||
|
{map(settingObject.children, (item, idx) => {
|
||||||
|
return (
|
||||||
|
<li key={idx}>
|
||||||
|
<a
|
||||||
|
className={
|
||||||
|
item.id.toString() === active ? "is-active" : ""
|
||||||
|
}
|
||||||
|
onClick={() => setActive(item.id.toString())}
|
||||||
|
>
|
||||||
|
{item.displayName}
|
||||||
|
</a>
|
||||||
|
{/* Second level children */}
|
||||||
|
{!isUndefined(item.children) ? (
|
||||||
|
<ul>
|
||||||
|
{map(item.children, (item, idx) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
className={
|
||||||
|
item.id.toString() === active
|
||||||
|
? "is-active"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setActive(item.id.toString())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.displayName}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content for settings */}
|
||||||
|
<div className="section column is-half mt-6">
|
||||||
|
<div className="content">
|
||||||
|
{map(settingsContent, ({ id, content }) =>
|
||||||
|
active === id ? content : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
32
src/client/components/SortableCover.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
|
export const SortableCover = (props) => {
|
||||||
|
const sortable = useSortable({ id: props.url });
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
isDragging,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = sortable;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cover
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
src/client/components/SystemSettings/SystemSettingsForm.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { ReactElement, useCallback } from "react";
|
||||||
|
import { flushDb } from "../../actions/settings.actions";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
|
export const SystemSettingsForm = (): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const isSettingsCallInProgress = useSelector(
|
||||||
|
(state: RootState) => state.settings.inProgress,
|
||||||
|
);
|
||||||
|
const flushDatabase = useCallback(() => {
|
||||||
|
dispatch(flushDb());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="is-clearfix">
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="title">Flush DB and Temporary Folders</h3>
|
||||||
|
<h6 className="subtitle has-text-grey-light">
|
||||||
|
If you are encountering issues, start over using this functionality.
|
||||||
|
</h6>
|
||||||
|
<article className="message is-danger">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
Flushing and resetting will clear out:
|
||||||
|
<p>
|
||||||
|
<small>(This action is irreversible)</small>
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>The mongo collection that holds library metadata</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Your <code>USERDATA_DIRECTORY</code> which includes
|
||||||
|
<code>covers</code>, <code>temporary</code> and
|
||||||
|
<code>expanded</code> subfolders.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Your <code>Elasticsearch indices</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="message is-info">
|
||||||
|
<div className="message-body is-size-6 is-family-secondary">
|
||||||
|
Your comic book files are not touched, and your settings will remain
|
||||||
|
intact.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
isSettingsCallInProgress
|
||||||
|
? "button is-danger is-loading"
|
||||||
|
: "button is-danger"
|
||||||
|
}
|
||||||
|
onClick={flushDatabase}
|
||||||
|
>
|
||||||
|
<span className="icon">
|
||||||
|
<i className="fas fa-eraser"></i>
|
||||||
|
</span>
|
||||||
|
<span>Flush DB & Temporary Folders</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemSettingsForm;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { isArray, map } from "lodash";
|
||||||
|
import React, { useEffect, ReactElement } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { getComicBooksDetailsByIds } from "../../actions/comicinfo.actions";
|
||||||
|
import { Card } from "../Carda";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
|
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
|
||||||
|
const PotentialLibraryMatches = (props): ReactElement => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const comicBooks = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.comicBooksDetails,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getComicBooksDetailsByIds(props.matches));
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="potential-matches-container mt-10">
|
||||||
|
{isArray(comicBooks) ? (
|
||||||
|
map(comicBooks, (match) => {
|
||||||
|
const encodedFilePath = encodeURI(
|
||||||
|
`${LIBRARY_SERVICE_HOST}/${match.rawFileDetails.cover.filePath}`,
|
||||||
|
);
|
||||||
|
const filePath = escapePoundSymbol(encodedFilePath);
|
||||||
|
return (
|
||||||
|
<div className="potential-issue-match mb-3">
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column is-one-fifth">
|
||||||
|
<Card
|
||||||
|
imageUrl={filePath}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-result-details column">
|
||||||
|
<div className="is-size-5">{match.rawFileDetails.name}</div>
|
||||||
|
<pre className="code is-size-7">
|
||||||
|
{match.rawFileDetails.containedIn}
|
||||||
|
</pre>
|
||||||
|
<div className="field is-grouped is-grouped-multiline mt-4">
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">File Type</span>
|
||||||
|
<span className="tag is-primary">
|
||||||
|
{match.rawFileDetails.extension}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">File Size</span>
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{prettyBytes(match.rawFileDetails.fileSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div>No matches found in library.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PotentialLibraryMatches;
|
||||||
258
src/client/components/VolumeDetail/VolumeDetail.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
|
||||||
|
import React, { useEffect, ReactElement, useState, useCallback } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import {
|
||||||
|
getComicBookDetailById,
|
||||||
|
getIssuesForSeries,
|
||||||
|
analyzeLibrary,
|
||||||
|
} from "../../actions/comicinfo.actions";
|
||||||
|
import PotentialLibraryMatches from "./PotentialLibraryMatches";
|
||||||
|
import Masonry from "react-masonry-css";
|
||||||
|
import { Card } from "../Carda";
|
||||||
|
import SlidingPane from "react-sliding-pane";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
|
||||||
|
const VolumeDetails = (props): ReactElement => {
|
||||||
|
const breakpointColumnsObj = {
|
||||||
|
default: 6,
|
||||||
|
1100: 4,
|
||||||
|
700: 3,
|
||||||
|
500: 2,
|
||||||
|
};
|
||||||
|
// sliding panel config
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
|
const [matches, setMatches] = useState([]);
|
||||||
|
const [active, setActive] = useState(1);
|
||||||
|
|
||||||
|
// sliding panel init
|
||||||
|
const contentForSlidingPanel = {
|
||||||
|
potentialMatchesInLibrary: {
|
||||||
|
content: () => {
|
||||||
|
const ids = map(matches, partialRight(pick, "_id"));
|
||||||
|
const matchIds = ids.map((id: any) => id._id);
|
||||||
|
return <PotentialLibraryMatches matches={matchIds} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// sliding panel handlers
|
||||||
|
const openPotentialLibraryMatchesPanel = useCallback((potentialMatches) => {
|
||||||
|
setSlidingPanelContentId("potentialMatchesInLibrary");
|
||||||
|
setMatches(potentialMatches);
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const analyzeIssues = useCallback((issues) => {
|
||||||
|
dispatch(analyzeLibrary(issues));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const comicBookDetails = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||||
|
);
|
||||||
|
const issuesForVolume = useSelector(
|
||||||
|
(state: RootState) => state.comicInfo.issuesForVolume,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getIssuesForSeries(comicObjectId));
|
||||||
|
dispatch(getComicBookDetailById(comicObjectId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
|
||||||
|
const IssuesInVolume = () => (
|
||||||
|
<>
|
||||||
|
{!isUndefined(issuesForVolume) ? (
|
||||||
|
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
|
||||||
|
Analyze Library
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Masonry
|
||||||
|
breakpointCols={breakpointColumnsObj}
|
||||||
|
className="issues-container"
|
||||||
|
columnClassName="issues-column"
|
||||||
|
>
|
||||||
|
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
|
||||||
|
? issuesForVolume.map((issue) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={issue.id}
|
||||||
|
imageUrl={issue.image.thumb_url}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails
|
||||||
|
borderColorClass={
|
||||||
|
!isEmpty(issue.matches) ? "green-border" : ""
|
||||||
|
}
|
||||||
|
backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
|
||||||
|
onClick={() =>
|
||||||
|
openPotentialLibraryMatchesPanel(issue.matches)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="tag is-warning mr-1">
|
||||||
|
{issue.issue_number}
|
||||||
|
</span>
|
||||||
|
{!isEmpty(issue.matches) ? (
|
||||||
|
<>
|
||||||
|
<span className="icon has-text-success">
|
||||||
|
<i className="fa-regular fa-asterisk"></i>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: "loading"}
|
||||||
|
</Masonry>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tab content and header details
|
||||||
|
const tabGroup = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Issues in Volume",
|
||||||
|
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||||
|
content: <IssuesInVolume key={1} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
icon: <i className="fa-regular fa-mask"></i>,
|
||||||
|
name: "Characters",
|
||||||
|
content: <div key={2}>asdasd</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
icon: <i className="fa-solid fa-scroll"></i>,
|
||||||
|
name: "Arcs",
|
||||||
|
content: <div key={3}>asdasd</div>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
const MetadataTabGroup = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="tabs">
|
||||||
|
<ul>
|
||||||
|
{tabGroup.map(({ id, name, icon }) => (
|
||||||
|
<li
|
||||||
|
key={id}
|
||||||
|
className={id === active ? "is-active" : ""}
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
>
|
||||||
|
<a>
|
||||||
|
<span className="icon is-small">{icon}</span>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{tabGroup.map(({ id, content }) => {
|
||||||
|
return active === id ? content : null;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isUndefined(comicBookDetails.sourcedMetadata) &&
|
||||||
|
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="container volume-details">
|
||||||
|
<div className="section">
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="title">
|
||||||
|
{comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
|
||||||
|
</h1>
|
||||||
|
<div className="columns is-multiline">
|
||||||
|
{/* Volume cover */}
|
||||||
|
<div className="column is-narrow">
|
||||||
|
<Card
|
||||||
|
imageUrl={
|
||||||
|
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||||
|
.image.small_url
|
||||||
|
}
|
||||||
|
cardContainerStyle={{ maxWidth: 275 }}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="column is-three-fifths">
|
||||||
|
<div className="field is-grouped mt-2">
|
||||||
|
{/* Comicvine Id */}
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">ComicVine Id</span>
|
||||||
|
<span className="tag is-info is-light">
|
||||||
|
{
|
||||||
|
comicBookDetails.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.id
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Publisher */}
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-warning is-light">Publisher</span>
|
||||||
|
<span className="tag is-volume-related">
|
||||||
|
{
|
||||||
|
comicBookDetails.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.publisher.name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deck */}
|
||||||
|
<div>
|
||||||
|
{!isEmpty(
|
||||||
|
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||||
|
.description,
|
||||||
|
)
|
||||||
|
? ellipsize(
|
||||||
|
convert(
|
||||||
|
comicBookDetails.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.description,
|
||||||
|
{
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
300,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||||
|
</div>
|
||||||
|
<MetadataTabGroup />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SlidingPane
|
||||||
|
isOpen={visible}
|
||||||
|
onRequestClose={() => setVisible(false)}
|
||||||
|
title={"Potential Matches in Library"}
|
||||||
|
width={"600px"}
|
||||||
|
>
|
||||||
|
{slidingPanelContentId !== "" &&
|
||||||
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
|
</SlidingPane>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeDetails;
|
||||||
185
src/client/components/Volumes/Volumes.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { searchIssue } from "../../actions/fileops.actions";
|
||||||
|
import Card from "../Carda";
|
||||||
|
import T2Table from "../shared/T2Table";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import { isUndefined } from "lodash";
|
||||||
|
|
||||||
|
export const Volumes = (props): ReactElement => {
|
||||||
|
const volumes = useSelector((state: RootState) => state.fileOps.volumes);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "volumes",
|
||||||
|
trigger: "volumesPage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const columnData = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
header: "Volume Details",
|
||||||
|
id: "volumeDetails",
|
||||||
|
minWidth: 450,
|
||||||
|
accessorKey: "_source",
|
||||||
|
cell: (row) => {
|
||||||
|
const foo = row.getValue();
|
||||||
|
return (
|
||||||
|
<div className="columns">
|
||||||
|
<div className="column">
|
||||||
|
<div className="comic-detail issue-metadata">
|
||||||
|
<dl>
|
||||||
|
<dd>
|
||||||
|
<div className="columns mt-2">
|
||||||
|
<div className="">
|
||||||
|
<Card
|
||||||
|
imageUrl={
|
||||||
|
foo.sourcedMetadata.comicvine.volumeInformation
|
||||||
|
.image.thumb_url
|
||||||
|
}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="column">
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<h6 className="name has-text-weight-medium mb-1">
|
||||||
|
{
|
||||||
|
foo.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.name
|
||||||
|
}
|
||||||
|
</h6>
|
||||||
|
</dt>
|
||||||
|
<dd className="is-size-7">
|
||||||
|
published by{" "}
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{
|
||||||
|
foo.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.publisher.name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7">
|
||||||
|
<span>
|
||||||
|
{ellipsize(
|
||||||
|
convert(
|
||||||
|
foo.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.description,
|
||||||
|
{
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
120,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7 mt-2">
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags">
|
||||||
|
<span className="tag is-success is-light has-text-weight-semibold">
|
||||||
|
Total Issues
|
||||||
|
</span>
|
||||||
|
<span className="tag is-success is-light">
|
||||||
|
{
|
||||||
|
foo.sourcedMetadata.comicvine
|
||||||
|
.volumeInformation.count_of_issues
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Download Status",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "Files",
|
||||||
|
accessorKey: "_source.acquisition.directconnect",
|
||||||
|
align: "right",
|
||||||
|
cell: (props) => {
|
||||||
|
const row = props.getValue();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.length > 0 ? (
|
||||||
|
<span className="tag is-warning">{row.length}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Type",
|
||||||
|
id: "Air",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Type",
|
||||||
|
id: "dcc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="section">
|
||||||
|
<div className="header-area">
|
||||||
|
<h1 className="title">Volumes</h1>
|
||||||
|
</div>
|
||||||
|
{!isUndefined(volumes.hits) && (
|
||||||
|
<div>
|
||||||
|
<div className="library">
|
||||||
|
<T2Table
|
||||||
|
sourceData={volumes?.hits}
|
||||||
|
totalPages={volumes.hits.length}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage: () => {},
|
||||||
|
previousPage: () => {},
|
||||||
|
}}
|
||||||
|
columns={columnData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Volumes;
|
||||||
175
src/client/components/WantedComics/WantedComics.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { searchIssue } from "../../actions/fileops.actions";
|
||||||
|
import SearchBar from "../Library/SearchBar";
|
||||||
|
import T2Table from "../shared/T2Table";
|
||||||
|
import { isEmpty, isUndefined } from "lodash";
|
||||||
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
|
|
||||||
|
export const WantedComics = (props): ReactElement => {
|
||||||
|
const wantedComics = useSelector(
|
||||||
|
(state: RootState) => state.fileOps.wantedComics,
|
||||||
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "wanted",
|
||||||
|
trigger: "wantedComicsPage"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columnData = [
|
||||||
|
{
|
||||||
|
header: "Comic Information",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "Details",
|
||||||
|
id: "comicDetails",
|
||||||
|
minWidth: 350,
|
||||||
|
accessorFn: data => data,
|
||||||
|
cell: (value) => <MetadataPanel data={value.getValue()} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Download Status",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "Files",
|
||||||
|
accessorKey: "acquisition",
|
||||||
|
align: "right",
|
||||||
|
cell: props => {
|
||||||
|
const { directconnect: { downloads } } = props.getValue();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
// flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{downloads.length > 0 ? (
|
||||||
|
<span className="tag is-warning">
|
||||||
|
{downloads.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Download Details",
|
||||||
|
id: "downloadDetails",
|
||||||
|
accessorKey: "acquisition",
|
||||||
|
cell: data => <ol>
|
||||||
|
{data.getValue().directconnect.downloads.map((download, idx) => {
|
||||||
|
return <li className="is-size-7" key={idx}>{download.name}</li>;
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Type",
|
||||||
|
id: "dcc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination control that fetches the next x (pageSize) items
|
||||||
|
* based on the y (pageIndex) offset from the Elasticsearch index
|
||||||
|
* @param {number} pageIndex
|
||||||
|
* @param {number} pageSize
|
||||||
|
* @returns void
|
||||||
|
*
|
||||||
|
**/
|
||||||
|
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: pageSize,
|
||||||
|
from: pageSize * pageIndex + 1,
|
||||||
|
},
|
||||||
|
type: "wanted",
|
||||||
|
trigger: "wantedComicsPage",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination control that fetches the previous x (pageSize) items
|
||||||
|
* based on the y (pageIndex) offset from the Elasticsearch index
|
||||||
|
* @param {number} pageIndex
|
||||||
|
* @param {number} pageSize
|
||||||
|
* @returns void
|
||||||
|
**/
|
||||||
|
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||||
|
let from = 0;
|
||||||
|
if (pageIndex === 2) {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - 17;
|
||||||
|
} else {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - 16;
|
||||||
|
}
|
||||||
|
dispatch(
|
||||||
|
searchIssue(
|
||||||
|
{
|
||||||
|
query: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
size: pageSize,
|
||||||
|
from,
|
||||||
|
},
|
||||||
|
type: "wanted",
|
||||||
|
trigger: "wantedComicsPage"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="container">
|
||||||
|
<div className="section">
|
||||||
|
<div className="header-area">
|
||||||
|
<h1 className="title">Wanted Comics</h1>
|
||||||
|
</div>
|
||||||
|
{!isEmpty(wantedComics) && (
|
||||||
|
<div>
|
||||||
|
<div className="library">
|
||||||
|
<T2Table
|
||||||
|
sourceData={wantedComics}
|
||||||
|
totalPages={wantedComics.length}
|
||||||
|
columns={columnData}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage: nextPage,
|
||||||
|
previousPage: previousPage,
|
||||||
|
}}
|
||||||
|
// rowClickHandler={navigateToComicDetail}
|
||||||
|
/>
|
||||||
|
{/* pagination controls */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WantedComics;
|
||||||
64
src/client/components/shared/Canvas.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export const Canvas = (data) => {
|
||||||
|
const { colorHistogramData } = data.data;
|
||||||
|
console.log(data);
|
||||||
|
const width = 559;
|
||||||
|
const height = 200;
|
||||||
|
const pixelRatio = window.devicePixelRatio;
|
||||||
|
|
||||||
|
const canvas = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const context = canvas.current.getContext("2d");
|
||||||
|
context.scale(pixelRatio, pixelRatio);
|
||||||
|
const guideHeight = 8;
|
||||||
|
const startY = height - guideHeight;
|
||||||
|
const dx = width / 256;
|
||||||
|
const dy = startY / colorHistogramData.maxBrightness;
|
||||||
|
context.lineWidth = dx;
|
||||||
|
context.fillStyle = "transparent";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
const x = i * dx;
|
||||||
|
|
||||||
|
// Red
|
||||||
|
context.strokeStyle = "rgba(220,0,0,0.5)";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, startY);
|
||||||
|
context.lineTo(x, startY - colorHistogramData.r[i] / 10);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
// Green
|
||||||
|
context.strokeStyle = "rgba(0,210,0,0.5)";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, startY);
|
||||||
|
context.lineTo(x, startY - colorHistogramData.g[i] / 10);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
// Blue
|
||||||
|
context.strokeStyle = "rgba(0,0,255,0.5)";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, startY);
|
||||||
|
context.lineTo(x, startY - colorHistogramData.b[i] / 10);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
// Guide
|
||||||
|
context.strokeStyle = "rgb(" + i + ", " + i + ", " + i + ")";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, startY);
|
||||||
|
context.lineTo(x, height);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dw = Math.floor(pixelRatio * width);
|
||||||
|
const dh = Math.floor(pixelRatio * height);
|
||||||
|
const style = { width, height };
|
||||||
|
return <canvas ref={canvas} width={dw} height={dh} style={style} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Canvas;
|
||||||
234
src/client/components/shared/MetadataPanel.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { Card } from "../Carda";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
import { find, isUndefined } from "lodash";
|
||||||
|
|
||||||
|
interface IMetadatPanelProps {
|
||||||
|
value: any;
|
||||||
|
children: any;
|
||||||
|
imageStyle: any;
|
||||||
|
titleStyle: any;
|
||||||
|
tagsStyle: any;
|
||||||
|
containerStyle: any;
|
||||||
|
}
|
||||||
|
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||||
|
const {
|
||||||
|
rawFileDetails,
|
||||||
|
inferredMetadata,
|
||||||
|
sourcedMetadata: { comicvine, locg },
|
||||||
|
} = props.data;
|
||||||
|
const { issueName, url, objectReference } = determineCoverFile({
|
||||||
|
comicvine,
|
||||||
|
locg,
|
||||||
|
rawFileDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataContentPanel = [
|
||||||
|
{
|
||||||
|
name: "rawFileDetails",
|
||||||
|
content: () => (
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<h6
|
||||||
|
className="name has-text-weight-medium mb-1"
|
||||||
|
style={props.titleStyle}
|
||||||
|
>
|
||||||
|
{issueName}
|
||||||
|
</h6>
|
||||||
|
</dt>
|
||||||
|
<dd className="is-size-7">
|
||||||
|
Is a part of{" "}
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{inferredMetadata.issue.name}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7 mt-2">
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags">
|
||||||
|
<span
|
||||||
|
className="tag is-success is-light has-text-weight-semibold"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
{rawFileDetails.extension}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="tag is-success is-light has-text-weight-semibold"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
{rawFileDetails.mimeType}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="tag is-success is-light"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
{prettyBytes(rawFileDetails.fileSize)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
{inferredMetadata.issue.number && (
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-light" style={props.tagsStyle}>
|
||||||
|
Issue #
|
||||||
|
</span>
|
||||||
|
<span className="tag is-warning" style={props.tagsStyle}>
|
||||||
|
{inferredMetadata.issue.number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "comicvine",
|
||||||
|
content: () =>
|
||||||
|
!isUndefined(comicvine) &&
|
||||||
|
!isUndefined(comicvine.volumeInformation) && (
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<h6
|
||||||
|
className="name has-text-weight-medium mb-1"
|
||||||
|
style={props.titleStyle}
|
||||||
|
>
|
||||||
|
{ellipsize(issueName, 18)}
|
||||||
|
</h6>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="is-size-7">
|
||||||
|
Is a part of{" "}
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{comicvine.volumeInformation.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7">
|
||||||
|
<span>
|
||||||
|
{ellipsize(
|
||||||
|
convert(comicvine.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
120,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7 mt-2">
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags">
|
||||||
|
<span
|
||||||
|
className="tag is-success is-light has-text-weight-semibold"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
{comicvine.volumeInformation.start_year}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="tag is-success is-light"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
{comicvine.volumeInformation.count_of_issues}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span
|
||||||
|
className="tag is-primary is-light"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
ComicVine ID
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="tag is-info is-light"
|
||||||
|
style={props.tagsStyle}
|
||||||
|
>
|
||||||
|
{comicvine.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "locg",
|
||||||
|
content: () => (
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<h6 className="name has-text-weight-medium mb-1">
|
||||||
|
{ellipsize(issueName, 28)}
|
||||||
|
</h6>
|
||||||
|
</dt>
|
||||||
|
<dd className="is-size-7">
|
||||||
|
<span>{ellipsize(locg.description, 120)}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dd className="is-size-7 mt-2">
|
||||||
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
|
<div className="control">
|
||||||
|
<span className="tags">
|
||||||
|
<span className="tag is-success is-light has-text-weight-semibold">
|
||||||
|
{locg.price}
|
||||||
|
</span>
|
||||||
|
<span className="tag is-success is-light">{locg.pulls}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<div className="tags has-addons">
|
||||||
|
<span className="tag is-primary is-light">rating</span>
|
||||||
|
<span className="tag is-info is-light">{locg.rating}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find the panel to display
|
||||||
|
const metadataPanel = find(metadataContentPanel, {
|
||||||
|
name: objectReference,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="column" style={props.containerStyle}>
|
||||||
|
<div className="comic-detail issue-metadata">
|
||||||
|
<dl>
|
||||||
|
<dd>
|
||||||
|
<div className="columns mt-2">
|
||||||
|
<div className="column is-3">
|
||||||
|
<Card
|
||||||
|
imageUrl={url}
|
||||||
|
orientation={"vertical"}
|
||||||
|
hasDetails={false}
|
||||||
|
imageStyle={props.imageStyle}
|
||||||
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="column">{metadataPanel.content()}</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetadataPanel;
|
||||||
162
src/client/components/shared/T2Table.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { ReactElement, useMemo, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import SearchBar from "../Library/SearchBar";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
useReactTable,
|
||||||
|
PaginationState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
export const T2Table = (tableOptions): ReactElement => {
|
||||||
|
const { sourceData, columns, paginationHandlers: { nextPage, previousPage }, totalPages, rowClickHandler } =
|
||||||
|
tableOptions;
|
||||||
|
|
||||||
|
const [{ pageIndex, pageSize }, setPagination] =
|
||||||
|
useState<PaginationState>({
|
||||||
|
pageIndex: 1,
|
||||||
|
pageSize: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination control to move forward one page
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const goToNextPage = () => {
|
||||||
|
setPagination({
|
||||||
|
pageIndex: pageIndex + 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
nextPage(pageIndex, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination control to move backward one page
|
||||||
|
* @returns void
|
||||||
|
**/
|
||||||
|
const goToPreviousPage = () => {
|
||||||
|
setPagination({
|
||||||
|
pageIndex: pageIndex - 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
previousPage(pageIndex, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: sourceData,
|
||||||
|
columns,
|
||||||
|
manualPagination: true,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
pageCount: sourceData.length ?? -1,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="columns table-controls">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="column is-half">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
{/* pagination controls */}
|
||||||
|
<nav className="pagination columns">
|
||||||
|
<div className="mr-4 has-text-weight-semibold has-text-left">
|
||||||
|
<p className="is-size-5">Page {pageIndex} of {Math.ceil(totalPages / pageSize)}</p>
|
||||||
|
{/* <p>{totalPages} comics in all</p> */}
|
||||||
|
</div>
|
||||||
|
<div className="field has-addons">
|
||||||
|
<div className="control">
|
||||||
|
<div className="button" onClick={() => goToPreviousPage()}> <i className="fas fa-chevron-left"></i></div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<div className="button" onClick={() => goToNextPage()}> <i className="fas fa-chevron-right"></i> </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field has-addons ml-5">
|
||||||
|
<p className="control">
|
||||||
|
<button className="button">
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fa-solid fa-list"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p className="control">
|
||||||
|
<button className="button">
|
||||||
|
<Link to="/library-grid">
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fa-solid fa-image"></i>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<table className="table is-hoverable">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup, idx) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header, idx) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map((row, idx) => {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => rowClickHandler(row)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map(cell => (
|
||||||
|
<td key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
T2Table.propTypes = {
|
||||||
|
sourceData: PropTypes.array,
|
||||||
|
totalPages: PropTypes.number,
|
||||||
|
columns: PropTypes.array,
|
||||||
|
paginationHandlers: PropTypes.shape({
|
||||||
|
nextPage: PropTypes.func,
|
||||||
|
previousPage: PropTypes.func,
|
||||||
|
}),
|
||||||
|
rowClickHandler: PropTypes.func,
|
||||||
|
};
|
||||||
|
export default T2Table;
|
||||||
@@ -6,14 +6,137 @@ export const CV_CLEANUP = "CV_CLEANUP";
|
|||||||
export const CV_API_GENERIC_FAILURE = "CV_API_GENERIC_FAILURE";
|
export const CV_API_GENERIC_FAILURE = "CV_API_GENERIC_FAILURE";
|
||||||
|
|
||||||
export const IMS_COMICBOOK_METADATA_FETCHED = "IMS_SOCKET_DATA_FETCHED";
|
export const IMS_COMICBOOK_METADATA_FETCHED = "IMS_SOCKET_DATA_FETCHED";
|
||||||
export const IMS_SOCKET_CONNECTION_CONNECTED =
|
|
||||||
"IMS_SOCKET_CONNECTION_CONNECTED";
|
|
||||||
export const IMS_SOCKET_CONNECTION_DISCONNECTED =
|
|
||||||
"IMS_SOCKET_CONNECTION_DISCONNECTED";
|
|
||||||
export const IMS_SOCKET_ERROR = "IMS_SOCKET_ERROR";
|
|
||||||
|
|
||||||
export const IMS_RAW_IMPORT_SUCCESSFUL = "IMS_RAW_IMPORT_SUCCESSFUL";
|
export const IMS_RAW_IMPORT_SUCCESSFUL = "IMS_RAW_IMPORT_SUCCESSFUL";
|
||||||
export const IMS_RAW_IMPORT_FAILED = "IMS_RAW_IMPORT_FAILED";
|
export const IMS_RAW_IMPORT_FAILED = "IMS_RAW_IMPORT_FAILED";
|
||||||
|
|
||||||
|
// Library service generic action types
|
||||||
|
export const LS_IMPORT_CALL_IN_PROGRESS = "LS_IMPORT_CALL_IN_PROGRESS";
|
||||||
|
// Library import bull mq queue control
|
||||||
|
export const LS_TOGGLE_IMPORT_QUEUE = "LS_TOGGLE_IMPORT_QUEUE";
|
||||||
|
export const LS_QUEUE_DRAINED = "LS_QUEUE_DRAINED";
|
||||||
|
|
||||||
|
// ComicVine Metadata
|
||||||
|
export const IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS =
|
||||||
|
"IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS";
|
||||||
|
export const IMS_CV_METADATA_IMPORT_SUCCESSFUL =
|
||||||
|
"IMS_CV_METADATA_IMPORT_SUCCESSFUL";
|
||||||
|
export const IMS_CV_METADATA_IMPORT_FAILED = "IMS_CV_METADATA_IMPORT_FAILED";
|
||||||
|
|
||||||
export const IMS_RECENT_COMICS_FETCHED = "IMS_RECENT_COMICS_FETCHED";
|
export const IMS_RECENT_COMICS_FETCHED = "IMS_RECENT_COMICS_FETCHED";
|
||||||
export const IMS_DATA_FETCH_ERROR = "IMS_DATA_FETCH_ERROR";
|
export const IMS_DATA_FETCH_ERROR = "IMS_DATA_FETCH_ERROR";
|
||||||
|
|
||||||
|
// Weekly pull list
|
||||||
|
export const CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS =
|
||||||
|
"CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS";
|
||||||
|
export const CV_WEEKLY_PULLLIST_FETCHED = "CV_WEEKLY_PULLLIST_FETCHED";
|
||||||
|
export const CV_WEEKLY_PULLLIST_ERROR = "CV_WEEKLY_PULLLIST_ERROR";
|
||||||
|
|
||||||
|
// Single or multiple comic book mongo objects
|
||||||
|
export const IMS_COMIC_BOOK_DB_OBJECT_FETCHED =
|
||||||
|
"IMS_COMIC_BOOK_DB_OBJECT_FETCHED";
|
||||||
|
export const IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED =
|
||||||
|
"IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED";
|
||||||
|
export const IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS =
|
||||||
|
"IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS";
|
||||||
|
export const IMS_COMIC_BOOK_DB_OBJECT_CALL_FAILED =
|
||||||
|
"IMS_COMIC_BOOK_DB_OBJECT_CALL_FAILED";
|
||||||
|
|
||||||
|
// wanted comics from CV, LoCG and other sources
|
||||||
|
export const IMS_WANTED_COMICS_FETCHED = "IMS_WANTED_COMICS_FETCHED";
|
||||||
|
|
||||||
|
// volume groups
|
||||||
|
export const IMS_COMIC_BOOK_GROUPS_FETCHED = "IMS_COMIC_BOOK_GROUPS_FETCHED";
|
||||||
|
export const IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS =
|
||||||
|
"IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS";
|
||||||
|
export const IMS_COMIC_BOOK_GROUPS_CALL_FAILED =
|
||||||
|
"IMS_COMIC_BOOK_GROUPS_CALL_FAILED";
|
||||||
|
export const VOLUMES_FETCHED="VOLUMES_FETCHED";
|
||||||
|
|
||||||
|
// search results from the Search service
|
||||||
|
export const SS_SEARCH_RESULTS_FETCHED = "SS_SEARCH_RESULTS_FETCHED";
|
||||||
|
export const SS_SEARCH_RESULTS_FETCHED_SPECIAL = "SS_SEARCH_RESULTS_FETCHED_SPECIAL";
|
||||||
|
export const SS_SEARCH_IN_PROGRESS = "SS_SEARCH_IN_PROGRESS";
|
||||||
|
export const SS_SEARCH_FAILED = "SS_SEARCH_FAILED";
|
||||||
|
|
||||||
|
// issues for a given volume
|
||||||
|
export const CV_ISSUES_METADATA_CALL_IN_PROGRESS =
|
||||||
|
"CV_ISSUES_METADATA_CALL_IN_PROGRESS";
|
||||||
|
export const CV_ISSUES_METADATA_FETCH_SUCCESS =
|
||||||
|
"CV_ISSUES_METADATA_FETCH_SUCCESS";
|
||||||
|
export const CV_ISSUES_METADATA_FETCH_FAILED =
|
||||||
|
"CV_ISSUES_METADATA_FETCH_FAILED";
|
||||||
|
export const CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS =
|
||||||
|
"CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS";
|
||||||
|
export const CV_ISSUES_FOR_VOLUME_IN_LIBRARY_UPDATED =
|
||||||
|
"CV_ISSUES_FOR_VOLUME_IN_LIBRARY_UPDATED";
|
||||||
|
export const CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED =
|
||||||
|
"CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED";
|
||||||
|
|
||||||
|
// extracted comic archive
|
||||||
|
export const IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS =
|
||||||
|
"IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS";
|
||||||
|
export const IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS =
|
||||||
|
"IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS";
|
||||||
|
export const IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_FAILED =
|
||||||
|
"IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_FAILED";
|
||||||
|
|
||||||
|
export const COMICBOOK_EXTRACTION_SUCCESS = "COMICBOOK_EXTRACTION_SUCCESS";
|
||||||
|
|
||||||
|
// Image file stats
|
||||||
|
export const IMG_ANALYSIS_CALL_IN_PROGRESS = "IMG_ANALYSIS_CALL_IN_PROGRESS";
|
||||||
|
export const IMG_ANALYSIS_DATA_FETCH_SUCCESS =
|
||||||
|
"IMG_ANALYSIS_DATA_FETCH_SUCCESS";
|
||||||
|
export const IMG_ANALYSIS_DATA_FETCH_ERROR = "IMG_ANALYSIS_DATA_FETCH_ERROR";
|
||||||
|
|
||||||
|
// library statistics
|
||||||
|
export const LIBRARY_STATISTICS_CALL_IN_PROGRESS =
|
||||||
|
"LIBRARY_STATISTICS_CALL_IN_PROGRESS";
|
||||||
|
export const LIBRARY_STATISTICS_FETCHED = "LIBRARY_STATISTICS_FETCHED";
|
||||||
|
export const LIBRARY_STATISTICS_FETCH_ERROR = "LIBRARY_STATISTICS_FETCH_ERROR";
|
||||||
|
|
||||||
|
// fileops cleanup
|
||||||
|
export const FILEOPS_STATE_RESET = "FILEOPS_STATE_RESET";
|
||||||
|
|
||||||
|
// AirDC++
|
||||||
|
export const AIRDCPP_SEARCH_IN_PROGRESS = "AIRDCPP_SEARCH_IN_PROGRESS";
|
||||||
|
export const AIRDCPP_SEARCH_RESULTS_ADDED = "AIRDCPP_SEARCH_RESULTS_ADDED";
|
||||||
|
export const AIRDCPP_SEARCH_RESULTS_UPDATED = "AIRDCPP_SEARCH_RESULTS_UPDATED";
|
||||||
|
export const AIRDCPP_SEARCH_COMPLETE = "AIRDCPP_SEARCH_COMPLETE";
|
||||||
|
|
||||||
|
// AirDC++ related library query for issues with bundles associated with them
|
||||||
|
export const LIBRARY_ISSUE_BUNDLES = "LIBRARY_ISSUE_BUNDLES";
|
||||||
|
|
||||||
|
export const AIRDCPP_HUB_SEARCHES_SENT = "AIRDCPP_HUB_SEARCHES_SENT";
|
||||||
|
export const AIRDCPP_RESULT_DOWNLOAD_INITIATED =
|
||||||
|
"AIRDCPP_RESULT_DOWNLOAD_INITIATED";
|
||||||
|
export const AIRDCPP_FILE_DOWNLOAD_COMPLETED =
|
||||||
|
"AIRDCPP_FILE_DOWNLOAD_COMPLETED";
|
||||||
|
export const LS_SINGLE_IMPORT = "LS_SINGLE_IMPORT";
|
||||||
|
export const AIRDCPP_BUNDLES_FETCHED = "AIRDCPP_BUNDLES_FETCHED";
|
||||||
|
export const AIRDCPP_DOWNLOAD_PROGRESS_TICK = "AIRDCPP_DOWNLOAD_PROGRESS_TICK";
|
||||||
|
export const AIRDCPP_SOCKET_CONNECTED = "AIRDCPP_SOCKET_CONNECTED";
|
||||||
|
export const AIRDCPP_SOCKET_DISCONNECTED = "AIRDCPP_SOCKET_DISCONNECTED";
|
||||||
|
|
||||||
|
// Transfers
|
||||||
|
export const AIRDCPP_TRANSFERS_FETCHED = "AIRDCPP_TRANSFERS_FETCHED";
|
||||||
|
|
||||||
|
// Comics marked as "wanted"
|
||||||
|
export const WANTED_COMICS_FETCHED = "WANTED_COMICS_FETCHED";
|
||||||
|
|
||||||
|
// LIBRARY SOCKET ENDPOINT
|
||||||
|
export const LS_IMPORT = "LS_IMPORT";
|
||||||
|
export const LS_COVER_EXTRACTED = "LS_COVER_EXTRACTED";
|
||||||
|
export const LS_COMIC_ADDED = "LS_COMIC_ADDED";
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
export const SETTINGS_CALL_IN_PROGRESS = "SETTINGS_CALL_IN_PROGRESS";
|
||||||
|
export const SETTINGS_OBJECT_FETCHED = "SETTINGS_OBJECT_FETCHED";
|
||||||
|
export const SETTINGS_CALL_FAILED = "SETTINGS_CALL_FAILED";
|
||||||
|
export const SETTINGS_OBJECT_DELETED = "SETTINGS_OBJECT_DELETED";
|
||||||
|
export const SETTINGS_DB_FLUSH_SUCCESS = "SETTINGS_DB_FLUSH_SUCCESS";
|
||||||
|
|
||||||
|
// Metron Metadata
|
||||||
|
export const METRON_DATA_FETCH_SUCCESS = "METRON_DATA_FETCH_SUCCESS";
|
||||||
|
export const METRON_DATA_FETCH_IN_PROGRESS = "METRON_DATA_FETCH_IN_PROGRESS";
|
||||||
|
export const METRON_DATA_FETCH_ERROR = "METRON_DATA_FETCH_ERROR";
|
||||||
@@ -1,4 +1,79 @@
|
|||||||
export const COMICBOOKINFO_SERVICE_URI =
|
export const hostURIBuilder = (options: Record<string, string>): string => {
|
||||||
"http://localhost:6050/api/comicbookinfo/";
|
return (
|
||||||
export const API_BASE_URI = "http://localhost:8050/api/";
|
options.protocol +
|
||||||
export const SOCKET_BASE_URI = "ws://localhost:3000/";
|
"://" +
|
||||||
|
options.host +
|
||||||
|
":" +
|
||||||
|
options.port +
|
||||||
|
options.apiPath
|
||||||
|
);
|
||||||
|
};
|
||||||
|
console.log(import.meta);
|
||||||
|
|
||||||
|
export const CORS_PROXY_SERVER_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "8050",
|
||||||
|
apiPath: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const COMICVINE_SERVICE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3080",
|
||||||
|
apiPath: "/api/comicvine",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const METRON_SERVICE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3080",
|
||||||
|
apiPath: "/api/metron",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const API_BASE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "8050",
|
||||||
|
apiPath: "/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LIBRARY_SERVICE_HOST = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3000",
|
||||||
|
apiPath: ``,
|
||||||
|
});
|
||||||
|
export const LIBRARY_SERVICE_BASE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3000",
|
||||||
|
apiPath: "/api/library",
|
||||||
|
});
|
||||||
|
export const SEARCH_SERVICE_BASE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3000",
|
||||||
|
apiPath: "/api/search",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETTINGS_SERVICE_BASE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3000",
|
||||||
|
apiPath: "/api/settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IMAGETRANSFORMATION_SERVICE_BASE_URI = hostURIBuilder({
|
||||||
|
protocol: "http",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3000",
|
||||||
|
apiPath: "/api/imagetransformation",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SOCKET_BASE_URI = hostURIBuilder({
|
||||||
|
protocol: "ws",
|
||||||
|
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||||
|
port: "3001",
|
||||||
|
apiPath: `/`,
|
||||||
|
});
|
||||||
|
|||||||
11
src/client/constants/search.constants.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const MODULE_URL = "search";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
MODULE_URL: MODULE_URL,
|
||||||
|
INSTANCES_URL: MODULE_URL,
|
||||||
|
|
||||||
|
SEARCH_TYPES_URL: MODULE_URL + "/types",
|
||||||
|
SEARCH_TYPES_UPDATED: "search_types_updated",
|
||||||
|
|
||||||
|
DEFAULT_SEARCH_TYPE: "any",
|
||||||
|
};
|
||||||
84
src/client/constants/settings/settingsMenu.json
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"category": "general",
|
||||||
|
"displayName": "General",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "gen-db",
|
||||||
|
"displayName": "Dashboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gen-gls",
|
||||||
|
"displayName": "Global Search"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"category": "acquisition",
|
||||||
|
"displayName": "Acquisition",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "adc",
|
||||||
|
"displayName": "AirDC++",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "adc-connection",
|
||||||
|
"displayName": "Connection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "adc-hubs",
|
||||||
|
"displayName": "Hubs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "adc-add-config",
|
||||||
|
"displayName": "Additional Configuration"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"category": "comicvine",
|
||||||
|
"displayName": "Comic Vine",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "api",
|
||||||
|
"displayName": "API"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "options",
|
||||||
|
"displayName": "Options"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"category": "system",
|
||||||
|
"displayName": "System",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "flushdb",
|
||||||
|
"displayName": "Flush DB & Temporary folders"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"category": "acknowledgments",
|
||||||
|
"displayName": "Acknowledgments",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "testers",
|
||||||
|
"displayName": "Testers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "software",
|
||||||
|
"displayName": "Software"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
107
src/client/context/AirDCPPSocket.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { isEmpty, isUndefined } from "lodash";
|
||||||
|
import React, { createContext, useEffect, useState } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { toggleAirDCPPSocketConnectionStatus } from "../actions/airdcpp.actions";
|
||||||
|
import { getSettings } from "../actions/settings.actions";
|
||||||
|
|
||||||
|
import AirDCPPSocket from "../services/DcppSearchService";
|
||||||
|
|
||||||
|
const AirDCPPSocketContextProvider = ({ children }) => {
|
||||||
|
// setter for settings for use in the context consumer
|
||||||
|
const setSettings = (settingsObject) => {
|
||||||
|
persistSettings({
|
||||||
|
...airDCPPState,
|
||||||
|
airDCPPState: {
|
||||||
|
settings: settingsObject,
|
||||||
|
socket: {},
|
||||||
|
socketConectionInformation: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 1. default zero-state for AirDC++ configuration
|
||||||
|
const initState = {
|
||||||
|
airDCPPState: {
|
||||||
|
settings: {},
|
||||||
|
socket: {},
|
||||||
|
socketConnectionInformation: {},
|
||||||
|
},
|
||||||
|
setSettings: setSettings,
|
||||||
|
};
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [airDCPPState, persistSettings] = useState(initState);
|
||||||
|
const airDCPPSettings = useSelector(
|
||||||
|
(state: RootState) => state.settings.data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. get settings from mongo
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getSettings());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2. If available, init AirDC++ Socket with those settings
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEmpty(airDCPPSettings)) {
|
||||||
|
initializeAirDCPPSocket(airDCPPSettings);
|
||||||
|
}
|
||||||
|
}, [airDCPPSettings]);
|
||||||
|
|
||||||
|
// Method to init AirDC++ Socket with supplied settings
|
||||||
|
const initializeAirDCPPSocket = async (configuration) => {
|
||||||
|
console.log("[AirDCPP]: Initializing socket...");
|
||||||
|
const {
|
||||||
|
directConnect: {
|
||||||
|
client: { host },
|
||||||
|
},
|
||||||
|
} = configuration;
|
||||||
|
|
||||||
|
const initializedAirDCPPSocket = new AirDCPPSocket({
|
||||||
|
protocol: `${host.protocol}`,
|
||||||
|
hostname: `${host.hostname}:${host.port}`,
|
||||||
|
username: `${host.username}`,
|
||||||
|
password: `${host.password}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// connect and disconnect handlers
|
||||||
|
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
|
||||||
|
dispatch(toggleAirDCPPSocketConnectionStatus("connected", sessionInfo));
|
||||||
|
};
|
||||||
|
initializedAirDCPPSocket.onDisconnected = async (
|
||||||
|
reason,
|
||||||
|
code,
|
||||||
|
wasClean,
|
||||||
|
) => {
|
||||||
|
dispatch(
|
||||||
|
toggleAirDCPPSocketConnectionStatus("disconnected", {
|
||||||
|
reason,
|
||||||
|
code,
|
||||||
|
wasClean,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketConnectionInformation = await initializedAirDCPPSocket.connect();
|
||||||
|
|
||||||
|
// update the state with the new socket connection information
|
||||||
|
persistSettings({
|
||||||
|
...airDCPPState,
|
||||||
|
airDCPPState: {
|
||||||
|
settings: configuration,
|
||||||
|
socket: initializedAirDCPPSocket,
|
||||||
|
socketConnectionInformation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// the Provider gives access to the context to its children
|
||||||
|
return (
|
||||||
|
<AirDCPPSocketContext.Provider value={airDCPPState}>
|
||||||
|
{children}
|
||||||
|
</AirDCPPSocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const AirDCPPSocketContext = createContext({
|
||||||
|
airDCPPState: {},
|
||||||
|
saveSettings: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
export { AirDCPPSocketContext, AirDCPPSocketContextProvider };
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react";
|
import React from "react";
|
||||||
import { render } from "react-dom";
|
import { render } from "react-dom";
|
||||||
import { Provider } from "react-redux";
|
import { Provider, connect } from "react-redux";
|
||||||
import { ConnectedRouter } from "connected-react-router";
|
import { HistoryRouter as Router } from "redux-first-history/rr6";
|
||||||
import configureStore, { history } from "./store/index";
|
import { store, history } from "./store/index";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./components/App";
|
import App from "./components/App";
|
||||||
|
|
||||||
const store = configureStore({});
|
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
|
const root = createRoot(rootEl);
|
||||||
|
|
||||||
render(
|
root.render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ConnectedRouter history={history}>
|
<Router history={history}>
|
||||||
<App />
|
<App />
|
||||||
</ConnectedRouter>
|
</Router>
|
||||||
</Provider>,
|
</Provider>,
|
||||||
rootEl,
|
|
||||||
);
|
);
|
||||||
|
|||||||
133
src/client/reducers/airdcpp.reducer.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
AIRDCPP_SEARCH_IN_PROGRESS,
|
||||||
|
AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||||
|
AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||||
|
AIRDCPP_HUB_SEARCHES_SENT,
|
||||||
|
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||||
|
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||||
|
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
|
||||||
|
AIRDCPP_BUNDLES_FETCHED,
|
||||||
|
AIRDCPP_TRANSFERS_FETCHED,
|
||||||
|
LIBRARY_ISSUE_BUNDLES,
|
||||||
|
AIRDCPP_SOCKET_CONNECTED,
|
||||||
|
AIRDCPP_SOCKET_DISCONNECTED,
|
||||||
|
} from "../constants/action-types";
|
||||||
|
import { LOCATION_CHANGE } from "redux-first-history";
|
||||||
|
import { isNil, isUndefined } from "lodash";
|
||||||
|
import { difference } from "../shared/utils/object.utils";
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
searchResults: [],
|
||||||
|
isAirDCPPSearchInProgress: false,
|
||||||
|
searchInfo: null,
|
||||||
|
searchInstance: null,
|
||||||
|
downloadResult: null,
|
||||||
|
bundleDBImportResult: null,
|
||||||
|
downloadFileStatus: {},
|
||||||
|
bundles: [],
|
||||||
|
transfers: [],
|
||||||
|
isAirDCPPSocketConnected: false,
|
||||||
|
airDCPPSessionInfo: {},
|
||||||
|
socketDisconnectionReason: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function airdcppReducer(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case AIRDCPP_SEARCH_RESULTS_ADDED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
searchResults: [...state.searchResults, action.groupedResult],
|
||||||
|
isAirDCPPSearchInProgress: true,
|
||||||
|
};
|
||||||
|
case AIRDCPP_SEARCH_RESULTS_UPDATED:
|
||||||
|
const bundleToUpdateIndex = state.searchResults.findIndex(
|
||||||
|
(bundle) => bundle.result.id === action.groupedResult.result.id,
|
||||||
|
);
|
||||||
|
const updatedState = [...state.searchResults];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isNil(
|
||||||
|
difference(updatedState[bundleToUpdateIndex], action.groupedResult),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
updatedState[bundleToUpdateIndex] = action.groupedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
searchResults: updatedState,
|
||||||
|
};
|
||||||
|
case AIRDCPP_SEARCH_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAirDCPPSearchInProgress: true,
|
||||||
|
};
|
||||||
|
case AIRDCPP_HUB_SEARCHES_SENT:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAirDCPPSearchInProgress: false,
|
||||||
|
searchInfo: action.searchInfo,
|
||||||
|
searchInstance: action.instance,
|
||||||
|
};
|
||||||
|
case AIRDCPP_RESULT_DOWNLOAD_INITIATED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
downloadResult: action.downloadResult,
|
||||||
|
bundleDBImportResult: action.bundleDBImportResult,
|
||||||
|
};
|
||||||
|
case AIRDCPP_DOWNLOAD_PROGRESS_TICK:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
downloadProgressData: action.downloadProgressData,
|
||||||
|
};
|
||||||
|
case AIRDCPP_BUNDLES_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
bundles: action.bundles,
|
||||||
|
};
|
||||||
|
case LIBRARY_ISSUE_BUNDLES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
issue_bundles: action.issue_bundles,
|
||||||
|
};
|
||||||
|
case AIRDCPP_FILE_DOWNLOAD_COMPLETED:
|
||||||
|
console.log("COMPLETED", action);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
case AIRDCPP_TRANSFERS_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transfers: action.bundles,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AIRDCPP_SOCKET_CONNECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAirDCPPSocketConnected: true,
|
||||||
|
airDCPPSessionInfo: action.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
case AIRDCPP_SOCKET_DISCONNECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isAirDCPPSocketConnected: false,
|
||||||
|
socketDisconnectionReason: action.data,
|
||||||
|
};
|
||||||
|
case LOCATION_CHANGE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
searchResults: [],
|
||||||
|
isAirDCPPSearchInProgress: false,
|
||||||
|
searchInfo: null,
|
||||||
|
searchInstance: null,
|
||||||
|
downloadResult: null,
|
||||||
|
bundleDBImportResult: null,
|
||||||
|
// bundles: [],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default airdcppReducer;
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {
|
|
||||||
CV_API_CALL_IN_PROGRESS,
|
|
||||||
CV_SEARCH_SUCCESS,
|
|
||||||
CV_CLEANUP,
|
|
||||||
} from "../constants/action-types";
|
|
||||||
const initialState = {
|
|
||||||
searchResults: [],
|
|
||||||
searchQuery: {},
|
|
||||||
inProgress: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function comicinfoReducer(state = initialState, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case CV_API_CALL_IN_PROGRESS:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
inProgress: true,
|
|
||||||
};
|
|
||||||
case CV_SEARCH_SUCCESS:
|
|
||||||
console.log("ACTION", action);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
searchResults: action.searchResults,
|
|
||||||
searchQuery: action.searchQueryObject,
|
|
||||||
inProgress: false,
|
|
||||||
};
|
|
||||||
case CV_CLEANUP:
|
|
||||||
return {
|
|
||||||
searchResults: [],
|
|
||||||
searchQuery: {},
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default comicinfoReducer;
|
|
||||||
138
src/client/reducers/comicinfo.reducer.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import {
|
||||||
|
CV_API_CALL_IN_PROGRESS,
|
||||||
|
CV_SEARCH_SUCCESS,
|
||||||
|
CV_CLEANUP,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
|
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
|
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||||
|
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||||
|
CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
|
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
|
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||||
|
LIBRARY_STATISTICS_FETCHED,
|
||||||
|
} from "../constants/action-types";
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
pullList: [],
|
||||||
|
libraryStatistics: [],
|
||||||
|
searchResults: [],
|
||||||
|
searchQuery: {},
|
||||||
|
inProgress: false,
|
||||||
|
comicBookDetail: {},
|
||||||
|
comicBooksDetails: [],
|
||||||
|
issuesForVolume: [],
|
||||||
|
IMS_inProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function comicinfoReducer(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case CV_API_CALL_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
inProgress: true,
|
||||||
|
};
|
||||||
|
case CV_SEARCH_SUCCESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
searchResults: action.searchResults,
|
||||||
|
searchQuery: action.searchQueryObject,
|
||||||
|
inProgress: false,
|
||||||
|
};
|
||||||
|
case IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
IMS_inProgress: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case IMS_COMIC_BOOK_DB_OBJECT_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
comicBookDetail: action.comicBookDetail,
|
||||||
|
IMS_inProgress: false,
|
||||||
|
};
|
||||||
|
case IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
comicBooksDetails: action.comicBooks,
|
||||||
|
IMS_inProgress: false,
|
||||||
|
};
|
||||||
|
case CV_CLEANUP:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
searchResults: [],
|
||||||
|
searchQuery: {},
|
||||||
|
issuesForVolume: [],
|
||||||
|
};
|
||||||
|
case CV_ISSUES_METADATA_CALL_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
inProgress: true,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
|
||||||
|
case CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
issuesForVolume: action.issues,
|
||||||
|
inProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED:
|
||||||
|
const updatedState = [...state.issuesForVolume];
|
||||||
|
action.matches.map((match) => {
|
||||||
|
updatedState.map((issue, idx) => {
|
||||||
|
const matches = [];
|
||||||
|
if (!isEmpty(match.hits.hits)) {
|
||||||
|
return match.hits.hits.map((hit) => {
|
||||||
|
if (
|
||||||
|
parseInt(issue.issue_number, 10) ===
|
||||||
|
hit._source.inferredMetadata.issue.number
|
||||||
|
) {
|
||||||
|
matches.push(hit);
|
||||||
|
const updatedIssueResult = { ...issue, matches };
|
||||||
|
updatedState[idx] = updatedIssueResult;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
issuesForVolume: updatedState,
|
||||||
|
};
|
||||||
|
|
||||||
|
case CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
inProgress: true,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
case CV_WEEKLY_PULLLIST_FETCHED: {
|
||||||
|
const foo = [];
|
||||||
|
action.data.map((item) => {
|
||||||
|
foo.push({issue: item})
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
inProgress: false,
|
||||||
|
pullList: foo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case LIBRARY_STATISTICS_CALL_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
inProgress: true,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
case LIBRARY_STATISTICS_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
inProgress: false,
|
||||||
|
libraryStatistics: action.data,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default comicinfoReducer;
|
||||||
@@ -1,18 +1,63 @@
|
|||||||
|
import { isUndefined, map } from "lodash";
|
||||||
|
import { LOCATION_CHANGE } from "redux-first-history";
|
||||||
|
import { determineCoverFile } from "../shared/utils/metadata.utils";
|
||||||
import {
|
import {
|
||||||
IMS_SOCKET_CONNECTION_CONNECTED,
|
|
||||||
IMS_SOCKET_CONNECTION_DISCONNECTED,
|
|
||||||
IMS_COMICBOOK_METADATA_FETCHED,
|
IMS_COMICBOOK_METADATA_FETCHED,
|
||||||
IMS_SOCKET_ERROR,
|
|
||||||
IMS_RAW_IMPORT_SUCCESSFUL,
|
IMS_RAW_IMPORT_SUCCESSFUL,
|
||||||
IMS_RAW_IMPORT_FAILED,
|
IMS_RAW_IMPORT_FAILED,
|
||||||
IMS_RECENT_COMICS_FETCHED,
|
IMS_RECENT_COMICS_FETCHED,
|
||||||
IMS_DATA_FETCH_ERROR,
|
IMS_WANTED_COMICS_FETCHED,
|
||||||
|
WANTED_COMICS_FETCHED,
|
||||||
|
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||||
|
IMS_CV_METADATA_IMPORT_FAILED,
|
||||||
|
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||||
|
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||||
|
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||||
|
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
||||||
|
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
|
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
|
||||||
|
LS_IMPORT,
|
||||||
|
LS_COVER_EXTRACTED,
|
||||||
|
LS_QUEUE_DRAINED,
|
||||||
|
LS_COMIC_ADDED,
|
||||||
|
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||||
|
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
|
SS_SEARCH_RESULTS_FETCHED,
|
||||||
|
SS_SEARCH_IN_PROGRESS,
|
||||||
|
FILEOPS_STATE_RESET,
|
||||||
|
LS_IMPORT_CALL_IN_PROGRESS,
|
||||||
|
SS_SEARCH_FAILED,
|
||||||
|
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||||
|
VOLUMES_FETCHED,
|
||||||
|
COMICBOOK_EXTRACTION_SUCCESS,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
|
import { removeLeadingPeriod } from "../shared/utils/formatting.utils";
|
||||||
|
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
dataTransferred: false,
|
IMSCallInProgress: false,
|
||||||
|
IMGCallInProgress: false,
|
||||||
|
SSCallInProgress: false,
|
||||||
|
imageAnalysisResults: {},
|
||||||
|
comicBookExtractionInProgress: false,
|
||||||
comicBookMetadata: [],
|
comicBookMetadata: [],
|
||||||
socketConnected: false,
|
comicVolumeGroups: [],
|
||||||
|
isSocketConnected: false,
|
||||||
|
isComicVineMetadataImportInProgress: false,
|
||||||
|
comicVineMetadataImportError: {},
|
||||||
rawImportError: {},
|
rawImportError: {},
|
||||||
|
extractedComicBookArchive: {
|
||||||
|
reading: [],
|
||||||
|
analysis: [],
|
||||||
|
},
|
||||||
|
recentComics: [],
|
||||||
|
wantedComics: [],
|
||||||
|
libraryComics: [],
|
||||||
|
volumes: [],
|
||||||
|
librarySearchResultsFormatted: [],
|
||||||
|
librarySearchResultCount: 0,
|
||||||
|
libraryQueueResults: [],
|
||||||
|
librarySearchError: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function fileOpsReducer(state = initialState, action) {
|
function fileOpsReducer(state = initialState, action) {
|
||||||
@@ -21,14 +66,15 @@ function fileOpsReducer(state = initialState, action) {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
comicBookMetadata: [...state.comicBookMetadata, action.data],
|
comicBookMetadata: [...state.comicBookMetadata, action.data],
|
||||||
dataTransferred: true,
|
IMSCallInProgress: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
case IMS_SOCKET_CONNECTION_CONNECTED:
|
case LS_IMPORT_CALL_IN_PROGRESS: {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
socketConnected: action.socketConnected,
|
IMSCallInProgress: true,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case IMS_RAW_IMPORT_SUCCESSFUL:
|
case IMS_RAW_IMPORT_SUCCESSFUL:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -42,11 +88,199 @@ function fileOpsReducer(state = initialState, action) {
|
|||||||
case IMS_RECENT_COMICS_FETCHED:
|
case IMS_RECENT_COMICS_FETCHED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
recentComics: action.data,
|
recentComics: action.data.docs,
|
||||||
};
|
};
|
||||||
|
case IMS_WANTED_COMICS_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
wantedComics: action.data,
|
||||||
|
};
|
||||||
|
case IMS_CV_METADATA_IMPORT_SUCCESSFUL:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isComicVineMetadataImportInProgress: false,
|
||||||
|
comicVineMetadataImportDetails: action.importResult,
|
||||||
|
};
|
||||||
|
case IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isComicVineMetadataImportInProgress: true,
|
||||||
|
};
|
||||||
|
case IMS_CV_METADATA_IMPORT_FAILED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isComicVineMetadataImportInProgress: false,
|
||||||
|
comicVineMetadataImportError: action.importError,
|
||||||
|
};
|
||||||
|
case IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
IMSCallInProgress: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case IMS_COMIC_BOOK_GROUPS_FETCHED: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
comicVolumeGroups: action.data,
|
||||||
|
IMSCallInProgress: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case IMS_COMIC_BOOK_GROUPS_CALL_FAILED: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
IMSCallInProgress: false,
|
||||||
|
error: action.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
comicBookExtractionInProgress: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case LOCATION_CHANGE: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
extractedComicBookArchive: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case LS_IMPORT: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case LS_COVER_EXTRACTED: {
|
||||||
|
console.log("BASH", action);
|
||||||
|
if(state.recentComics.length === 5) {
|
||||||
|
state.recentComics.pop();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
librarySearchResultCount: state.librarySearchResultCount + 1,
|
||||||
|
recentComics: [...state.recentComics, action.result.data.importResult]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case COMICBOOK_EXTRACTION_SUCCESS: {
|
||||||
|
const comicBookPages: string[] = [];
|
||||||
|
map(action.result.files, (page) => {
|
||||||
|
const pageFilePath = removeLeadingPeriod(page);
|
||||||
|
const imagePath = encodeURI(`${LIBRARY_SERVICE_HOST}${pageFilePath}`);
|
||||||
|
comicBookPages.push(imagePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (action.result.purpose) {
|
||||||
|
case "reading":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
extractedComicBookArchive: {
|
||||||
|
reading: comicBookPages,
|
||||||
|
},
|
||||||
|
comicBookExtractionInProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case "analysis":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
extractedComicBookArchive: {
|
||||||
|
analysis: comicBookPages,
|
||||||
|
},
|
||||||
|
comicBookExtractionInProgress: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case LS_QUEUE_DRAINED: {
|
||||||
|
console.log("drained", action);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case LS_COMIC_ADDED: {
|
||||||
|
console.log("ADDED na anna", action);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case IMG_ANALYSIS_CALL_IN_PROGRESS: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
IMGCallInProgress: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case IMG_ANALYSIS_DATA_FETCH_SUCCESS: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
imageAnalysisResults: action.result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case SS_SEARCH_IN_PROGRESS: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
SSCallInProgress: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case SS_SEARCH_RESULTS_FETCHED: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
libraryComics: action.data,
|
||||||
|
SSCallInProgress: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case SS_SEARCH_RESULTS_FETCHED_SPECIAL: {
|
||||||
|
const foo = [];
|
||||||
|
if (!isUndefined(action.data.hits)) {
|
||||||
|
map(action.data.hits, ({ _source }) => {
|
||||||
|
foo.push(_source);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
librarySearchResultsFormatted: foo,
|
||||||
|
SSCallInProgress: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case WANTED_COMICS_FETCHED: {
|
||||||
|
const foo = [];
|
||||||
|
if (!isUndefined(action.data.hits)) {
|
||||||
|
map(action.data.hits, ({ _source }) => {
|
||||||
|
foo.push(_source);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
wantedComics: foo,
|
||||||
|
SSCallInProgress: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case VOLUMES_FETCHED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
volumes: action.data,
|
||||||
|
SSCallInProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case SS_SEARCH_FAILED: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
librarySearchError: action.data,
|
||||||
|
SSCallInProgress: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case FILEOPS_STATE_RESET: {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
imageAnalysisResults: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fileOpsReducer;
|
export default fileOpsReducer;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { combineReducers } from "redux";
|
|
||||||
import { connectRouter } from "connected-react-router";
|
|
||||||
import comicinfoReducer from "../reducers/comicinfo.reducer";
|
import comicinfoReducer from "../reducers/comicinfo.reducer";
|
||||||
import fileOpsReducer from "../reducers/fileops.reducer";
|
import fileOpsReducer from "../reducers/fileops.reducer";
|
||||||
|
import airdcppReducer from "../reducers/airdcpp.reducer";
|
||||||
|
import settingsReducer from "../reducers/settings.reducer";
|
||||||
|
|
||||||
export default (history) =>
|
export const reducers = {
|
||||||
combineReducers({
|
comicInfo: comicinfoReducer,
|
||||||
comicInfo: comicinfoReducer,
|
fileOps: fileOpsReducer,
|
||||||
fileOps: fileOpsReducer,
|
airdcpp: airdcppReducer,
|
||||||
router: connectRouter(history),
|
settings: settingsReducer,
|
||||||
});
|
};
|
||||||
|
|||||||