Compare commits
618 Commits
v0.0.1
...
dark-mode-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7739d1ced7 | |||
| 0d438ab913 | |||
| a7b9138f52 | |||
| 0872aa4d8a | |||
| 4d1d29a361 | |||
| b1d8951842 | |||
| 1180a2b42c | |||
| 5ffcb0d642 | |||
| f293d9e3ba | |||
| 1f6d6ca6b6 | |||
| a7508df0b3 | |||
| 81b590157e | |||
| 72a308801d | |||
| e1be413ff6 | |||
| 435056048b | |||
|
|
68442894d0 | ||
| 026d5832c5 | |||
| a068b82db3 | |||
| c74c443534 | |||
| 701d216e44 | |||
| 4034c4e2b4 | |||
| 9c4f2ae9b0 | |||
| a07c0357f0 | |||
| 4215470871 | |||
| 38599fbc78 | |||
| 7f3a1caa3c | |||
| d36138c800 | |||
| 1ed6a622d4 | |||
| 29e0772a10 | |||
| 57b713aca1 | |||
| dfd99e45b6 | |||
| 591ecb394c | |||
| 145427d3fd | |||
| dba520b4c1 | |||
| ef75dad4e2 | |||
| 8bebffd95e | |||
|
|
1bd3d611e4 | ||
|
|
825782fe13 | ||
| 32f4055daa | |||
|
|
c20f24b1a2 | ||
|
|
e4fc28f698 | ||
|
|
d6c183339f | ||
|
|
0eba47e20f | ||
| 928bfd573e | |||
| b8fb179ac6 | |||
| 94692bb6d4 | |||
| 4b795aca5d | |||
| 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 = {
|
||||
extends: [
|
||||
"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.
|
||||
],
|
||||
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"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
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: {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx"],
|
||||
},
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
},
|
||||
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
|
||||
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/
|
||||
dist/
|
||||
src/client/assets/scss/App.css
|
||||
server/
|
||||
/server/
|
||||
node_modules/
|
||||
src/**/*.jsx
|
||||
tests/__coverage__/
|
||||
tests/**/*.jsx
|
||||
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
|
||||
|
||||
19
.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-onboarding",
|
||||
"@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 5173
|
||||
|
||||
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]
|
||||
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Three Two!</title>
|
||||
</head>
|
||||
|
||||
<body class="dark:bg-slate-600">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
jsdoc.json
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"tags": { "allowUnknownTags": true },
|
||||
"source": {
|
||||
"include": ["./src/"],
|
||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||
},
|
||||
"plugins": [
|
||||
"better-docs/component",
|
||||
"better-docs/category",
|
||||
"plugins/markdown",
|
||||
"node_modules/better-docs/typescript"
|
||||
],
|
||||
"templates": { "better-docs": { "name": "My React components" } },
|
||||
"opts": {
|
||||
"destination": "docs/",
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"encoding": "utf8",
|
||||
"verbose": true,
|
||||
"template": "node_modules/better-docs"
|
||||
}
|
||||
}
|
||||
"tags": {
|
||||
"allowUnknownTags": false
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"./src/client"
|
||||
],
|
||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"opts": {
|
||||
"template": "node_modules/tui-jsdoc-template",
|
||||
"encoding": "utf8",
|
||||
"destination": "docs/",
|
||||
"recurse": true,
|
||||
"verbose": true
|
||||
},
|
||||
"templates": {
|
||||
"cleverLinks": false,
|
||||
"monospaceLinks": false
|
||||
}
|
||||
}
|
||||
208
package.json
@@ -5,146 +5,122 @@
|
||||
"main": "server/index.js",
|
||||
"typings": "server/index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"start": "npm run build && npm run server",
|
||||
"client": "webpack serve --mode development --devtool inline-source-map --hot",
|
||||
"server": "tsc -p tsconfig.server.json && node server/",
|
||||
"dev": "concurrently \"nodemon\" \"npm run client\"",
|
||||
"server-dev": "nodemon",
|
||||
"docs": "jsdoc -c jsdoc.json"
|
||||
"build": "vite build",
|
||||
"dev": "rimraf dist && npm run build && vite",
|
||||
"start": "npm run build && vite",
|
||||
"docs": "jsdoc -c jsdoc.json",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"author": "Rishi Ghan",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.17",
|
||||
"@bluelovers/fast-glob": "^3.0.4",
|
||||
"@types/event-stream": "^3.3.34",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@redux-devtools/extension": "^3.2.5",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@tanstack/react-query": "^5.0.5",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/react": "^17.0.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-client": "^3.0.0",
|
||||
"@types/through2": "^2.0.36",
|
||||
"airdcpp-apisocket": "^2.4.1",
|
||||
"antd": "^4.16.5",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"better-docs": "^2.3.2",
|
||||
"calibre-opds": "^1.0.7",
|
||||
"comlink-loader": "^2.0.0",
|
||||
"ellipsize": "^0.1.0",
|
||||
"event-stream": "^4.0.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||
"axios": "^1.3.4",
|
||||
"axios-cache-interceptor": "^1.0.1",
|
||||
"axios-rate-limit": "^1.3.0",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"dayjs": "^1.10.6",
|
||||
"ellipsize": "^0.5.1",
|
||||
"express": "^4.17.1",
|
||||
"fastest-validator": "^1.11.0",
|
||||
"filename-parser": "^1.0.2",
|
||||
"final-form": "^4.20.2",
|
||||
"fs-extra": "^9.1.0",
|
||||
"http-response-stream": "^1.0.7",
|
||||
"imghash": "^0.0.8",
|
||||
"jsdoc": "^3.6.7",
|
||||
"opds-extra": "^3.0.9",
|
||||
"final-form-arrays": "^3.0.2",
|
||||
"history": "^5.3.0",
|
||||
"html-to-text": "^8.1.0",
|
||||
"immer": "^10.0.3",
|
||||
"jsdoc": "^3.6.10",
|
||||
"lodash": "^4.17.21",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"react": "^17.0.1",
|
||||
"react-collapsible": "^2.8.3",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-final-form": "^6.5.3",
|
||||
"react-spinners": "^0.11.0",
|
||||
"react-window-dynamic-list": "^2.3.5",
|
||||
"sharp": "^0.28.1",
|
||||
"socket.io-client": "^4.1.2",
|
||||
"threetwo-ui-typings": "^1.0.1",
|
||||
"voca": "^1.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.10.5",
|
||||
"react": "^18.2.0",
|
||||
"react-collapsible": "^2.9.0",
|
||||
"react-comic-viewer": "^0.4.0",
|
||||
"react-day-picker": "^8.6.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-final-form": "^6.5.9",
|
||||
"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-router": "^6.9.0",
|
||||
"react-router-dom": "^6.9.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-select-async-paginate": "^0.7.2",
|
||||
"react-sliding-pane": "^7.1.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-textarea-autosize": "^8.3.4",
|
||||
"reapop": "^4.2.1",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"styled-components": "^6.1.0",
|
||||
"threetwo-ui-typings": "^1.0.14",
|
||||
"vite": "^5.0.5",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"websocket": "^1.0.34",
|
||||
"ws": "^7.5.3",
|
||||
"ws-calibre": "bluelovers/ws-calibre",
|
||||
"xregexp": "^5.0.2"
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.13.10",
|
||||
"@babel/core": "^7.13.10",
|
||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||
"@babel/preset-env": "^7.13.10",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||
"@root/walk": "^1.1.0",
|
||||
"@iconify-json/solar": "^1.1.8",
|
||||
"@iconify/tailwind": "^0.1.4",
|
||||
"@storybook/addon-essentials": "^7.4.1",
|
||||
"@storybook/addon-interactions": "^7.4.1",
|
||||
"@storybook/addon-links": "^7.4.1",
|
||||
"@storybook/addon-onboarding": "^1.0.8",
|
||||
"@storybook/blocks": "^7.4.1",
|
||||
"@storybook/react": "^7.4.1",
|
||||
"@storybook/react-vite": "^7.4.1",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.0.5",
|
||||
"@tanstack/react-query-devtools": "^5.1.0",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@types/ellipsize": "^0.1.1",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/mongoose": "^5.7.37",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/pino": "^6.3.7",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/unzipper": "^0.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^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-loader": "^8.2.2",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"body-parser": "^1.19.0",
|
||||
"buffer": "^6.0.3",
|
||||
"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-config-airbnb": "^18.2.1",
|
||||
"eslint-config-airbnb-base": "^14.2.1",
|
||||
"docdash": "^2.0.2",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-css-modules": "^2.11.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsdoc": "^46.6.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"etl": "^0.6.12",
|
||||
"eslint-plugin-storybook": "^0.6.13",
|
||||
"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",
|
||||
"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",
|
||||
"npm": "^7.9.0",
|
||||
"pino": "^6.11.2",
|
||||
"pino-pretty": "^4.7.1",
|
||||
"jest": "^29.6.3",
|
||||
"nodemon": "^3.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-import": "^15.1.0",
|
||||
"prettier": "^2.2.1",
|
||||
"qs": "^6.10.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-hot-loader": "^4.13.0",
|
||||
"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",
|
||||
"typescript": "^4.2.3",
|
||||
"unzipper": "^0.10.11",
|
||||
"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"
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^4.1.3",
|
||||
"sass": "^1.69.5",
|
||||
"storybook": "^7.3.2",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/Hasklig-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Heavy.otf
Normal file
BIN
public/fonts/PPObjectSans-HeavySlanted.otf
Normal file
BIN
public/fonts/PPObjectSans-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Slanted.otf
Normal file
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Three Two!</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
178
src/client/actions/airdcpp.actions.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const 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 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,51 @@
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
import qs from "qs";
|
||||
import { setupCache } from "axios-cache-interceptor";
|
||||
import {
|
||||
CV_SEARCH_SUCCESS,
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
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";
|
||||
import { COMICBOOKINFO_SERVICE_URI } from "../constants/endpoints";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
|
||||
const http = rateLimiter(axios.create(), {
|
||||
maxRequests: 1,
|
||||
perMilliseconds: 1000,
|
||||
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) => {
|
||||
try {
|
||||
@@ -20,7 +53,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||
type: CV_API_CALL_IN_PROGRESS,
|
||||
inProgress: true,
|
||||
});
|
||||
const serviceURI = COMICBOOKINFO_SERVICE_URI + options.callURIAction;
|
||||
const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`;
|
||||
const response = await http(serviceURI, {
|
||||
method: options.callMethod,
|
||||
params: options.callParams,
|
||||
@@ -29,16 +62,13 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
paramsSerializer: (params) => {
|
||||
return qs.stringify(params, { arrayFormat: "repeat" });
|
||||
},
|
||||
});
|
||||
|
||||
switch (options.callURIAction) {
|
||||
case "search":
|
||||
dispatch({
|
||||
type: CV_SEARCH_SUCCESS,
|
||||
result: response.data,
|
||||
searchResults: response.data,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -53,3 +83,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,62 @@
|
||||
import axios from "axios";
|
||||
import { IFolderData, IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
||||
import { API_BASE_URI, SOCKET_BASE_URI } from "../constants/endpoints";
|
||||
import { io } from "socket.io-client";
|
||||
import { IFolderData } from "threetwo-ui-typings";
|
||||
import {
|
||||
IMS_COMICBOOK_METADATA_FETCHED,
|
||||
IMS_SOCKET_CONNECTION_CONNECTED,
|
||||
COMICVINE_SERVICE_URI,
|
||||
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
SEARCH_SERVICE_BASE_URI,
|
||||
JOB_QUEUE_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
import {
|
||||
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||
IMS_RECENT_COMICS_FETCHED,
|
||||
IMS_WANTED_COMICS_FETCHED,
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
CV_SEARCH_SUCCESS,
|
||||
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_CALL_IN_PROGRESS,
|
||||
SS_SEARCH_RESULTS_FETCHED,
|
||||
SS_SEARCH_IN_PROGRESS,
|
||||
FILEOPS_STATE_RESET,
|
||||
LS_IMPORT_CALL_IN_PROGRESS,
|
||||
SS_SEARCH_FAILED,
|
||||
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
WANTED_COMICS_FETCHED,
|
||||
VOLUMES_FETCHED,
|
||||
LIBRARY_SERVICE_HEALTH,
|
||||
LS_SET_QUEUE_STATUS,
|
||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { refineQuery } from "../shared/utils/filenameparser.utils";
|
||||
import { matchScorer } from "../shared/utils/searchmatchscorer.utils";
|
||||
import { success } from "react-notification-system-redux";
|
||||
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const getServiceStatus = (serviceName?: string) => async (dispatch) => {
|
||||
axios
|
||||
.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
|
||||
method: "GET",
|
||||
transformResponse: (r: string) => JSON.parse(r),
|
||||
})
|
||||
.then((response) => {
|
||||
const { data } = response;
|
||||
dispatch({
|
||||
type: LIBRARY_SERVICE_HEALTH,
|
||||
status: data,
|
||||
});
|
||||
});
|
||||
};
|
||||
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||
return axios
|
||||
.request<Array<IFolderData>>({
|
||||
url: "http://localhost:3000/api/import/walkFolders",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
|
||||
method: "POST",
|
||||
data: {
|
||||
basePathToWalk: path,
|
||||
@@ -31,131 +71,315 @@ export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||
}
|
||||
/**
|
||||
* Fetches comic book covers along with some metadata
|
||||
*
|
||||
* using {@link Renderer}.
|
||||
*
|
||||
* Used by external plugins
|
||||
*
|
||||
* @param {Object} options
|
||||
* @return {Promise<string>} HTML of the page
|
||||
* @return the comic book metadata
|
||||
*/
|
||||
export const fetchComicBookMetadata = (options) => async (dispatch) => {
|
||||
const extractionOptions = {
|
||||
sourceFolder: options,
|
||||
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,
|
||||
export const fetchComicBookMetadata = () => async (dispatch) => {
|
||||
dispatch({
|
||||
type: LS_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log(`connect ${socket.id}`);
|
||||
dispatch({
|
||||
type: IMS_SOCKET_CONNECTION_CONNECTED,
|
||||
socketConnected: true,
|
||||
});
|
||||
// dispatch(
|
||||
// success({
|
||||
// // uid: 'once-please', // you can specify your own uid if required
|
||||
// title: "Import Started",
|
||||
// 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,
|
||||
// }),
|
||||
// );
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
dispatch({
|
||||
type: LS_IMPORT,
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`disconnect`);
|
||||
});
|
||||
socket.emit("importComicsInDB", {
|
||||
action: "getComicCovers",
|
||||
params: {
|
||||
extractionOptions,
|
||||
walkedFolders,
|
||||
},
|
||||
});
|
||||
|
||||
socket.on("comicBookCoverMetadata", (data: IExtractedComicBookCoverFile) => {
|
||||
dispatch({
|
||||
type: IMS_COMICBOOK_METADATA_FETCHED,
|
||||
data,
|
||||
dataTransferred: true,
|
||||
});
|
||||
await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
});
|
||||
};
|
||||
|
||||
export const getRecentlyImportedComicBooks = (options) => async (dispatch) => {
|
||||
const { paginationOptions } = options;
|
||||
return axios
|
||||
.request({
|
||||
url: "http://localhost:3000/api/import/getRecentlyImportedComicBooks",
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
export const getImportJobResultStatistics = () => async (dispatch) => {
|
||||
const result = await axios.request({
|
||||
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
|
||||
method: "GET",
|
||||
});
|
||||
dispatch({
|
||||
type: LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
data: result.data,
|
||||
});
|
||||
};
|
||||
|
||||
export const setQueueControl =
|
||||
(queueAction: string, queueStatus: string) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: LS_SET_QUEUE_STATUS,
|
||||
meta: { remote: true },
|
||||
data: { queueAction, queueStatus },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const response = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions,
|
||||
predicate,
|
||||
},
|
||||
});
|
||||
|
||||
switch (comicStatus) {
|
||||
case "recent":
|
||||
dispatch({
|
||||
type: IMS_RECENT_COMICS_FETCHED,
|
||||
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 {
|
||||
const issueString = searchPayload.rawFileDetails.path.split("/").pop();
|
||||
let seriesSearchQuery = {};
|
||||
const issueSearchQuery = refineQuery(issueString);
|
||||
if (searchPayload.rawFileDetails.containedIn !== "comics") {
|
||||
seriesSearchQuery = refineQuery(
|
||||
searchPayload.rawFileDetails.containedIn.split("/").pop(),
|
||||
);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: CV_API_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
axios
|
||||
.request({
|
||||
url: "http://localhost:3080/api/comicvine/fetchseries",
|
||||
method: "POST",
|
||||
data: {
|
||||
format: "json",
|
||||
sort: "name%3Aasc",
|
||||
query: issueSearchQuery.searchParams.searchTerms.name,
|
||||
fieldList: "id",
|
||||
limit: "10",
|
||||
offset: "0",
|
||||
resources: "issue",
|
||||
/**
|
||||
* Makes a call to library service to import the comic book metadata into the ThreeTwo data store.
|
||||
* @returns Nothing.
|
||||
* @param payload
|
||||
*/
|
||||
export const importToDB =
|
||||
(sourceName: string, metadata?: any) => (dispatch) => {
|
||||
try {
|
||||
const comicBookMetadata = {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
sourcedMetadata: metadata || null,
|
||||
acquisition: { source: { wanted: true, name: sourceName } },
|
||||
},
|
||||
transformResponse: [
|
||||
(r) => {
|
||||
const searchMatches = JSON.parse(r);
|
||||
return matchScorer(searchMatches.results, {
|
||||
issue: issueSearchQuery,
|
||||
series: seriesSearchQuery,
|
||||
});
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
dispatch({
|
||||
type: CV_SEARCH_SUCCESS,
|
||||
searchResults: response.data,
|
||||
searchQueryObject: {
|
||||
issue: issueSearchQuery,
|
||||
series: seriesSearchQuery,
|
||||
},
|
||||
});
|
||||
};
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
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) {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
77
src/client/actions/settings.actions.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
SETTINGS_OBJECT_FETCHED,
|
||||
SETTINGS_CALL_IN_PROGRESS,
|
||||
SETTINGS_DB_FLUSH_SUCCESS,
|
||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||
} from "../reducers/settings.reducer";
|
||||
import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
SETTINGS_SERVICE_BASE_URI,
|
||||
QBITTORRENT_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
|
||||
await axios.request({
|
||||
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
|
||||
method: "POST",
|
||||
data: hostInfo,
|
||||
});
|
||||
const qBittorrentClientInfo = await axios.request({
|
||||
url: `${QBITTORRENT_SERVICE_BASE_URI}/getClientInfo`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||
data: qBittorrentClientInfo.data,
|
||||
});
|
||||
};
|
||||
|
||||
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};
|
||||
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,177 +1,15 @@
|
||||
@import "../../../../node_modules/bulma/bulma.sass";
|
||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
||||
$bg-color: yellow;
|
||||
$border-color: red;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.app {
|
||||
font-family: helvetica, arial, sans-serif;
|
||||
padding: 2em;
|
||||
border: 5px solid $border-color;
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Regular";
|
||||
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||
}
|
||||
|
||||
p {
|
||||
background-color: $bg-color;
|
||||
}
|
||||
}
|
||||
.navbar-item.is-mega {
|
||||
position: static;
|
||||
|
||||
.is-mega-menu-title {
|
||||
margin-bottom: 0;
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.min {
|
||||
overflow: visible;
|
||||
.tags {
|
||||
display: inline;
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
pre {
|
||||
border-radius: 0.4em;
|
||||
margin: 10px 0 10px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.generic-card {
|
||||
max-width: 200px;
|
||||
|
||||
.truncate {
|
||||
width: 100px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
.card-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
column-gap: 0.5em;
|
||||
row-gap: 1.2em;
|
||||
|
||||
.card {
|
||||
max-width: 200px;
|
||||
margin: 0 0 15px 0;
|
||||
.is-horizontal {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
flex-basis: 50ex;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
box-shadow: none;
|
||||
|
||||
.card-image {
|
||||
align-self: center;
|
||||
.image {
|
||||
max-width: 60px;
|
||||
img {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-left-radius: 0.25em;
|
||||
border-bottom-left-radius: 0.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
.card-content {
|
||||
align-self: center;
|
||||
flex: 1;
|
||||
padding-left: 1em;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
font-size: 0.8em;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comic-vine-match-drawer {
|
||||
// comic detail drawer
|
||||
.search-criteria-card {
|
||||
width: 100%;
|
||||
.card-content {
|
||||
padding: 10px;
|
||||
.ant-divider-horizontal {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin: 5px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// comicvine search results
|
||||
.search-results-container {
|
||||
margin: 15px 0 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
tbody tr:nth-child(odd) {
|
||||
background: #f6f6f6;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-result {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 10px;
|
||||
margin: 0 0 10px 0;
|
||||
|
||||
.cover-image {
|
||||
border-radius: 5px;
|
||||
}
|
||||
.search-result-details {
|
||||
width: 100%;
|
||||
.score {
|
||||
float: right;
|
||||
}
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// progress
|
||||
.progress-indicator-container {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.indicator {
|
||||
padding: 5px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
@font-face {
|
||||
font-family: "Hasklig Regular";
|
||||
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { hot } from "react-hot-loader";
|
||||
import Dashboard from "./Dashboard";
|
||||
|
||||
import Import from "./Import";
|
||||
import { ComicDetail } from "./ComicDetail";
|
||||
|
||||
import { Switch, Route } from "react-router";
|
||||
import Navbar from "./Navbar";
|
||||
import React, { ReactElement } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar2 } from "./shared/Navbar2";
|
||||
import "../assets/scss/App.scss";
|
||||
|
||||
class App extends React.Component {
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Dashboard />
|
||||
</Route>
|
||||
<Route path="/import">
|
||||
<Import path={"./comics"} />
|
||||
</Route>
|
||||
<Route
|
||||
path={"/comic/details/:comicObjectId"}
|
||||
component={ComicDetail}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export const App = (): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<Navbar2 />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
declare let module: Record<string, unknown>;
|
||||
|
||||
export default hot(module)(App);
|
||||
export default App;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
501
src/client/components/ComicDetail/AcquisitionPanel.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
||||
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { difference } from "../../shared/utils/object.utils";
|
||||
import { isEmpty, isNil, map } from "lodash";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
interface IAcquisitionPanelProps {
|
||||
query: any;
|
||||
comicObjectId: any;
|
||||
comicObject: any;
|
||||
settings: any;
|
||||
}
|
||||
|
||||
export const AcquisitionPanel = (
|
||||
props: IAcquisitionPanelProps,
|
||||
): ReactElement => {
|
||||
const {
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPDownloadTick,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPDownloadTick: state.airDCPPDownloadTick,
|
||||
})),
|
||||
);
|
||||
|
||||
interface SearchData {
|
||||
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
|
||||
hub_urls: string[] | undefined | null;
|
||||
priority: PriorityEnum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
||||
});
|
||||
const { comicObjectId } = props;
|
||||
const issueName = props.query.issue.name || "";
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
||||
|
||||
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
||||
// Pre-populate the search input with the search string, so that
|
||||
// All the user has to do is hit "Search AirDC++"
|
||||
useEffect(() => {
|
||||
// AirDC++ search query
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Method to perform a search via an AirDC++ websocket
|
||||
* @param {SearchData} data - a SearchData query
|
||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
||||
*/
|
||||
const search = async (data: SearchData, ADCPPSocket: any) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket();
|
||||
}
|
||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
||||
setAirDCPPSearchStatus(true);
|
||||
|
||||
// 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)
|
||||
setAirDCPPSearchResults((state) => [...state, 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
|
||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
||||
(bundle) => bundle.result.id === groupedResult.result.id,
|
||||
);
|
||||
const updatedState = [...airDCPPSearchResults];
|
||||
if (
|
||||
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
|
||||
) {
|
||||
updatedState[bundleToUpdateIndex] = groupedResult;
|
||||
}
|
||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||
},
|
||||
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}`,
|
||||
);
|
||||
setAirDCPPSearchInstance(currentInstance);
|
||||
setAirDCPPSearchInfo(searchInfo);
|
||||
console.log("Asdas", airDCPPSearchInfo);
|
||||
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
|
||||
setAirDCPPSearchInstance(instance);
|
||||
setAirDCPPSearchStatus(false);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
// Finally, perform the actual search
|
||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to download a bundle associated with a search result from AirDC++
|
||||
* @param {Number} searchInstanceId - description
|
||||
* @param {String} resultId - description
|
||||
* @param {String} comicObjectId - description
|
||||
* @param {String} name - description
|
||||
* @param {Number} size - description
|
||||
* @param {any} type - description
|
||||
* @param {any} ADCPPSocket - description
|
||||
* @returns {void} - description
|
||||
*/
|
||||
const download = async (
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
comicObjectId: String,
|
||||
name: String,
|
||||
size: Number,
|
||||
type: any,
|
||||
ADCPPSocket: any,
|
||||
): void => {
|
||||
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: `http://localhost:3000/api/library/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;
|
||||
}
|
||||
};
|
||||
const getDCPPSearchResults = async (searchQuery) => {
|
||||
const manualQuery = {
|
||||
query: {
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs, (hub) => hub.hub_url),
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
search(manualQuery, airDCPPSocketInstance);
|
||||
};
|
||||
|
||||
// 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}`,
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="comic-detail columns">
|
||||
{!isEmpty(airDCPPSocketInstance) ? (
|
||||
<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"></span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
|
||||
<div className="column">
|
||||
<button
|
||||
type="submit"
|
||||
className={
|
||||
airDCPPSearchStatus
|
||||
? "button is-loading is-warning"
|
||||
: "button is-success is-light"
|
||||
}
|
||||
>
|
||||
<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 > AirDC++ > Connection</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AirDC++ search instance details */}
|
||||
{!isNil(airDCPPSearchInstance) &&
|
||||
!isEmpty(airDCPPSearchInfo) &&
|
||||
!isNil(hubs) && (
|
||||
<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">
|
||||
{hubs.map((value, idx) => (
|
||||
<span className="tag is-warning" key={idx}>
|
||||
{value.identity.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dt>
|
||||
<dt>
|
||||
Query:
|
||||
<span className="has-text-weight-semibold">
|
||||
{airDCPPSearchInfo.query.pattern}
|
||||
</span>
|
||||
</dt>
|
||||
<dd>
|
||||
Extensions:
|
||||
<span className="has-text-weight-semibold">
|
||||
{airDCPPSearchInfo.query.extensions.join(", ")}
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
File type:
|
||||
<span className="has-text-weight-semibold">
|
||||
{airDCPPSearchInfo.query.file_type}
|
||||
</span>
|
||||
</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: {airDCPPSearchInstance.id}</dt>
|
||||
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
|
||||
<dd>Expires in: {airDCPPSearchInstance.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>
|
||||
<button
|
||||
className="button is-small is-light is-success"
|
||||
onClick={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
result.id,
|
||||
comicObjectId,
|
||||
result.name,
|
||||
result.size,
|
||||
result.type,
|
||||
airDCPPSocketInstance,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-file-download"></i>
|
||||
</span>
|
||||
<span>Download </span>
|
||||
</button>
|
||||
</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">
|
||||
<p>
|
||||
The default search term is an auto-detected title; you may
|
||||
need to change it to get better matches if the auto-detected
|
||||
one doesn't work.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="message is-warning">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
<p className="content">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcquisitionPanel;
|
||||
103
src/client/components/ComicDetail/ActionMenu/Menu.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { filter, isEmpty, isNil, isUndefined } from "lodash";
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
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 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);
|
||||
}, [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;
|
||||
}
|
||||
};
|
||||
const customStyles = {
|
||||
option: (base, { data, isDisabled, isFocused, isSelected }) => {
|
||||
return {
|
||||
...base,
|
||||
backgroundColor: isFocused ? "gray" : "black",
|
||||
};
|
||||
},
|
||||
control: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "black",
|
||||
border: "1px solid #CCC",
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
components={{ Placeholder }}
|
||||
placeholder={
|
||||
<span>
|
||||
<i className="fa-solid fa-list"></i> Actions
|
||||
</span>
|
||||
}
|
||||
styles={customStyles}
|
||||
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;
|
||||
327
src/client/components/ComicDetail/ComicDetail.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import React, { useState, ReactElement, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Card from "../shared/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 },
|
||||
acquisition,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
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-circle-nodes"></i>,
|
||||
name: "DC++ Search",
|
||||
content: (
|
||||
<AcquisitionPanel
|
||||
query={airDCPPQuery}
|
||||
comicObjectId={_id}
|
||||
comicObject={data.data}
|
||||
userSettings={userSettings}
|
||||
key={4}
|
||||
/>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: <i className="fa-solid fa-droplet"></i>,
|
||||
name: "Torrent Search",
|
||||
content: <>Torrents</>,
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
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 mx-auto">
|
||||
<div className="section">
|
||||
{!isNil(data) && !isEmpty(data) && (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex flex-row mt-5">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
|
||||
{/* raw file details */}
|
||||
{!isUndefined(rawFileDetails) &&
|
||||
!isEmpty(rawFileDetails.cover) && (
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
rawFileDetails: rawFileDetails,
|
||||
inferredMetadata: inferredMetadata,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <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>
|
||||
)}
|
||||
{/* action dropdown */}
|
||||
{/* <div className="mt-4 is-size-7">
|
||||
<Menu
|
||||
data={data.data}
|
||||
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabControls
|
||||
filteredTabs={filteredTabs}
|
||||
acquisition={acquisition}
|
||||
/>
|
||||
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Comic Vine Search Matches"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComicDetail;
|
||||
42
src/client/components/ComicDetail/ComicDetailContainer.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
|
||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
|
||||
export const ComicDetailContainer = (): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const {
|
||||
data: comicBookDetailData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: [],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
data: {
|
||||
id: comicObjectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
console.log(comicBookDetailData);
|
||||
useEffect(() => {
|
||||
// dispatch(getComicBookDetailById(comicObjectId));
|
||||
// dispatch(getSettings());
|
||||
}, []);
|
||||
|
||||
{
|
||||
isError && <>Error</>;
|
||||
}
|
||||
{
|
||||
isLoading && <>Loading...</>;
|
||||
}
|
||||
return (
|
||||
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
|
||||
);
|
||||
};
|
||||
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 "../shared/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;
|
||||
@@ -1,22 +1,29 @@
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import Collapsible from "react-collapsible";
|
||||
import { fetchComicVineMatches } from "../../actions/fileops.actions";
|
||||
|
||||
/**
|
||||
* Component for accepting ComicVine search parameters
|
||||
* Component for performing search against ComicVine
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* const age = 21
|
||||
* const name = 'Jitendra Nirnejak'
|
||||
* return (
|
||||
* <User age={age} name={name} />
|
||||
* <ComicVineSearchForm data={rawFileDetails} />
|
||||
* )
|
||||
*/
|
||||
export const ComicVineSearchForm = () => {
|
||||
const onSubmit = () => {
|
||||
return true;
|
||||
};
|
||||
export const ComicVineSearchForm = (data) => {
|
||||
const onSubmit = useCallback((value) => {
|
||||
const userInititatedQuery = {
|
||||
inferredIssueDetails: {
|
||||
name: value.issueName,
|
||||
number: value.issueNumber,
|
||||
subtitle: "",
|
||||
year: value.issueYear,
|
||||
},
|
||||
};
|
||||
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
||||
}, []);
|
||||
const validate = () => {
|
||||
return true;
|
||||
};
|
||||
@@ -28,7 +35,7 @@ export const ComicVineSearchForm = () => {
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<span className="field is-normal">
|
||||
<label className="label">Issue Details</label>
|
||||
<label className="label mb-2 is-size-5">Search Manually</label>
|
||||
</span>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-body">
|
||||
@@ -48,11 +55,15 @@ export const ComicVineSearchForm = () => {
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<Field name="issueNumber">
|
||||
{(props) => (
|
||||
<p className="control is-expanded has-icons-left">
|
||||
<p className="control has-icons-left">
|
||||
<input
|
||||
{...props.input}
|
||||
className="input is-normal"
|
||||
@@ -65,18 +76,36 @@ export const ComicVineSearchForm = () => {
|
||||
)}
|
||||
</Field>
|
||||
</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 className="field is-horizontal">
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button
|
||||
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">
|
||||
<i className="fas fa-hand-sparkles"></i>
|
||||
<i className="fas fa-search"></i>
|
||||
</span>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
@@ -89,16 +118,7 @@ export const ComicVineSearchForm = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
trigger={"Match Manually"}
|
||||
triggerTagName="a"
|
||||
triggerClassName={"is-size-6"}
|
||||
triggerOpenedClassName={"is-size-6"}
|
||||
>
|
||||
<MyForm />
|
||||
</Collapsible>
|
||||
);
|
||||
return <MyForm />;
|
||||
};
|
||||
|
||||
export default ComicVineSearchForm;
|
||||
34
src/client/components/ComicDetail/DownloadProgressTick.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export const DownloadProgressTick = (props): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="is-size-5">{props.data.name}</h4>
|
||||
<div>
|
||||
<span className="is-size-4 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-6 mt-1 mb-2">
|
||||
<p>{prettyBytes(props.data.speed)} per second.</p>
|
||||
Time left:
|
||||
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
||||
</div>
|
||||
|
||||
<div>{props.data.target}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadProgressTick;
|
||||
103
src/client/components/ComicDetail/DownloadsPanel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useContext, ReactElement } from "react";
|
||||
import { getBundlesForComic } from "../../actions/airdcpp.actions";
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, [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;
|
||||
342
src/client/components/ComicDetail/EditMetadataPanel.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
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,
|
||||
// );
|
||||
|
||||
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;
|
||||
124
src/client/components/ComicDetail/MatchResult.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { isNil, map } from "lodash";
|
||||
import { applyComicVineMatch } from "../../actions/comicinfo.actions";
|
||||
import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
interface MatchResultProps {
|
||||
matchData: any;
|
||||
comicObjectId: string;
|
||||
}
|
||||
|
||||
const handleBrokenImage = (e) => {
|
||||
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
|
||||
};
|
||||
|
||||
export const MatchResult = (props: MatchResultProps) => {
|
||||
const applyCVMatch = useCallback(
|
||||
// (match, comicObjectId) => {
|
||||
// dispatch(applyComicVineMatch(match, comicObjectId));
|
||||
// },
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{map(props.matchData, (match, idx) => {
|
||||
let issueDescription = "";
|
||||
if (!isNil(match.description)) {
|
||||
issueDescription = convert(match.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="search-result mb-4" key={idx}>
|
||||
<div className="columns">
|
||||
<div className="column is-one-fifth">
|
||||
<img
|
||||
className="cover-image"
|
||||
src={match.image.thumb_url}
|
||||
onError={handleBrokenImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="search-result-details column">
|
||||
<div className="is-size-5">{match.name}</div>
|
||||
|
||||
<div className="field is-grouped is-grouped-multiline mt-1">
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Number</span>
|
||||
<span className="tag is-primary">
|
||||
{match.issue_number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Cover Date</span>
|
||||
<span className="tag is-warning">{match.cover_date}</span>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchResult;
|
||||
167
src/client/components/ComicDetail/RawFileDetails.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
export const RawFileDetails = (props): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||
props.data;
|
||||
const PaperClipIcon = () => <></>;
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-2xl ml-5">
|
||||
<div className="px-4 sm:px-6">
|
||||
<p className="text-gray-500">
|
||||
<span className="text-xl">{rawFileDetails.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Raw File Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
rawFileDetails.name +
|
||||
rawFileDetails.extension}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Inferred Issue Metadata
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
Series Name: {inferredMetadata.issue.name}
|
||||
{!isEmpty(inferredMetadata.issue.number) ? (
|
||||
<span className="tag is-primary is-light">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500">MIMEType</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{/* File extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
<dd className="text-sm text-gray-900"></dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500">File Size</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{/* size */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Import Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(created_at), "h aaaa")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500">Attachments</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
<ul
|
||||
role="list"
|
||||
className="divide-y divide-gray-200 rounded-md border border-gray-200"
|
||||
>
|
||||
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
|
||||
<div className="flex w-0 flex-1 items-center">
|
||||
<PaperClipIcon
|
||||
className="h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-2 w-0 flex-1 truncate">
|
||||
resume_back_end_developer.pdf
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<a
|
||||
href="#"
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
|
||||
<div className="flex w-0 flex-1 items-center">
|
||||
{/* Read comic button */}
|
||||
<button
|
||||
className="button is-success is-light"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<i className="fa-solid fa-book-open mr-2"></i>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<a
|
||||
href="#"
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</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,
|
||||
}),
|
||||
}),
|
||||
created_at: PropTypes.string,
|
||||
updated_at: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
53
src/client/components/ComicDetail/TabControls.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { isEmpty, isNil } from "lodash";
|
||||
|
||||
export const TabControls = (props): ReactElement => {
|
||||
// const comicBookDetailData = useSelector(
|
||||
// (state: RootState) => state.comicInfo.comicBookDetail,
|
||||
// );
|
||||
const { filteredTabs, acquisition } = props;
|
||||
const [active, setActive] = useState(filteredTabs[0].id);
|
||||
useEffect(() => {
|
||||
setActive(filteredTabs[0].id);
|
||||
}, [acquisition]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden sm:block mt-7">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-6" aria-label="Tabs">
|
||||
{filteredTabs.map(({ id, name, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
className="inline-flex shrink-0 items-center gap-2 border-b-2 border-transparent px-1 pb-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
{/* <a>
|
||||
{id === 6 && !isNil(acquisition.directconnect) ? (
|
||||
<span className="download-icon-labels">
|
||||
<i className="fa-solid fa-download"></i>
|
||||
<span className="tag downloads-count is-info is-light">
|
||||
{acquisition.directconnect.downloads.length}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="icon is-small">{icon}</span>
|
||||
)}
|
||||
{name}
|
||||
</a> */}
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{filteredTabs.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabControls;
|
||||
127
src/client/components/ComicDetail/Tabs/ArchiveOperations.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import { DnD } from "../../shared/Draggable/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 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;
|
||||
59
src/client/components/ComicDetail/Tabs/ComicInfoXML.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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">
|
||||
{!isUndefined(json.summary) && 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,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);
|
||||
190
src/client/components/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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, isUndefined } from "lodash";
|
||||
import Header from "../shared/Header";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Card } from "../shared/Carda";
|
||||
import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_HOST,
|
||||
} from "../../constants/endpoints";
|
||||
import {
|
||||
determineCoverFile,
|
||||
determineExternalMetadata,
|
||||
} from "../../shared/utils/metadata.utils";
|
||||
|
||||
export const Dashboard = (): ReactElement => {
|
||||
const { data: recentComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": false },
|
||||
comicStatus: "recent",
|
||||
},
|
||||
}),
|
||||
queryKey: ["recentComics"],
|
||||
});
|
||||
|
||||
console.log("hari om", recentComics);
|
||||
// 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 mx-auto max-w-full">
|
||||
<section>
|
||||
<h1>Dashboard</h1>
|
||||
<div className="grid grid-cols-5 gap-6">
|
||||
{recentComics?.data.docs.map(
|
||||
(
|
||||
{
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
inferredMetadata,
|
||||
acquisition: {
|
||||
source: { name },
|
||||
},
|
||||
},
|
||||
idx,
|
||||
) => {
|
||||
const { issueName, url } = determineCoverFile({
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||
name,
|
||||
{
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
},
|
||||
);
|
||||
const isComicVineMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
|
||||
return (
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
key={idx}
|
||||
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
|
||||
title={inferredMetadata.issue.name}
|
||||
hasDetails
|
||||
>
|
||||
<div>
|
||||
<dt className="sr-only">Address</dt>
|
||||
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline]"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
</span>
|
||||
{/* File extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.extension}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-1 my-2">
|
||||
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
|
||||
{/* ComicInfo.xml presence */}
|
||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300"></i>
|
||||
)}
|
||||
{/* ComicVine metadata presence */}
|
||||
{isComicVineMetadataAvailable && (
|
||||
<span className="w-7 h-7">
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Raw file presence */}
|
||||
{isNil(rawFileDetails) && (
|
||||
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
|
||||
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</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;
|
||||
162
src/client/components/Dashboard/PullList.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { isNil, map } from "lodash";
|
||||
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
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-9-9",
|
||||
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">
|
||||
<Header headerContent="Discover"
|
||||
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"/>
|
||||
<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 "../shared/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 "../shared/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";
|
||||
|
||||
interface ZeroStateProps {
|
||||
header: string;
|
||||
header: string;
|
||||
message: string;
|
||||
}
|
||||
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
||||
return (
|
||||
<article className="message is-info">
|
||||
<div className="message-body">
|
||||
<p>{ props.header }</p>
|
||||
{ props.message }
|
||||
<p>{props.header}</p>
|
||||
{props.message}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZeroState;
|
||||
export default ZeroState;
|
||||
122
src/client/components/Downloads/Downloads.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { getTransfers } from "../../actions/airdcpp.actions";
|
||||
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 "../shared/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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { isUndefined } from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import { fetchComicBookMetadata } from "../actions/fileops.actions";
|
||||
import { IFolderData } from "threetwo-ui-typings";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||
import DynamicList, { createCache } from "react-window-dynamic-list";
|
||||
|
||||
interface IProps {
|
||||
matches: unknown;
|
||||
fetchComicMetadata: any;
|
||||
path: string;
|
||||
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({
|
||||
searchPaneIndex: paneId,
|
||||
});
|
||||
}
|
||||
|
||||
public initiateSocketConnection = () => {
|
||||
if (typeof this.props.path !== "undefined") {
|
||||
socket = io(SOCKET_BASE_URI, {
|
||||
reconnectionDelayMax: 10000,
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log(`connect ${socket.id}`);
|
||||
});
|
||||
this.props.fetchComicMetadata();
|
||||
}
|
||||
};
|
||||
|
||||
public cache = createCache();
|
||||
|
||||
public renderRow = ({ index, style }) => (
|
||||
<div style={style} className="min is-size-7">
|
||||
<span className="tag is-light">{index}</span>
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-success">cover</span>
|
||||
<span className="tag is-success is-light has-text-weight-medium">
|
||||
{this.props.covers[index].comicBookCoverMetadata.name}
|
||||
</span>
|
||||
</div>
|
||||
imported from
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-success">path</span>
|
||||
<span className="tag is-success is-light has-text-weight-medium">
|
||||
{this.props.covers[index].comicBookCoverMetadata.path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="db-import-result-panel">
|
||||
<pre className="has-background-success-light">
|
||||
<span className="icon">
|
||||
<i className="fas fa-database"></i>
|
||||
</span>
|
||||
{JSON.stringify(this.props.covers[index].dbImportResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
public render() {
|
||||
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 };
|
||||
298
src/client/components/Import/Import.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { ReactElement, useCallback, useEffect } from "react";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
import { format } from "date-fns";
|
||||
import Loader from "react-loader-spinner";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import axios from "axios";
|
||||
|
||||
interface IProps {
|
||||
matches?: unknown;
|
||||
fetchComicMetadata?: any;
|
||||
path: string;
|
||||
covers?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to facilitate the import of comics to the ThreeTwo library
|
||||
*
|
||||
* @param x - The first input number
|
||||
* @param y - The second input number
|
||||
* @returns The arithmetic mean of `x` and `y`
|
||||
*
|
||||
* @beta
|
||||
*/
|
||||
|
||||
export const Import = (props: IProps): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const { importJobQueue, socketIOInstance } = useStore(
|
||||
useShallow((state) => ({
|
||||
importJobQueue: state.importJobQueue,
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
);
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
const { mutate: initiateImport } = useMutation({
|
||||
mutationFn: async () =>
|
||||
await axios.request({
|
||||
url: `http://localhost:3000/api/library/newImport`,
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
}),
|
||||
});
|
||||
|
||||
const { data, isError, isLoading } = useQuery({
|
||||
queryKey: ["allImportJobResults"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
method: "GET",
|
||||
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
|
||||
}),
|
||||
});
|
||||
|
||||
const toggleQueue = (queueAction: string, queueStatus: string) => {
|
||||
socketIOInstance.emit(
|
||||
"call",
|
||||
"socket.setQueueStatus",
|
||||
{
|
||||
queueAction,
|
||||
queueStatus,
|
||||
},
|
||||
(data) => console.log(data),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Method to render import job queue pause/resume controls on the UI
|
||||
*
|
||||
* @param status The `string` status (either `"pause"` or `"resume"`)
|
||||
* @returns ReactElement A `<button/>` that toggles queue status
|
||||
* @remarks Sets the global `importJobQueue.status` state upon toggling
|
||||
*/
|
||||
const renderQueueControls = (status: string): ReactElement | null => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => {
|
||||
toggleQueue("pause", "paused");
|
||||
importJobQueue.setStatus("paused");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Pause</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "paused":
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => {
|
||||
toggleQueue("resume", "running");
|
||||
importJobQueue.setStatus("running");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Resume</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "drained":
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Import
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Import comics into the ThreeTwo library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
Importing will add comics identified from the mapped folder into
|
||||
ThreeTwo's database.
|
||||
</p>
|
||||
<p>
|
||||
Metadata from ComicInfo.xml, if present, will also be extracted.
|
||||
</p>
|
||||
<p>
|
||||
This process could take a while, if you have a lot of comics, or
|
||||
are importing over a network connection.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="my-4">
|
||||
{importJobQueue.status === "drained" ||
|
||||
(importJobQueue.status === undefined && (
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => {
|
||||
initiateImport();
|
||||
importJobQueue.setStatus("running");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Start Import</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
{(importJobQueue.status === "running" ||
|
||||
importJobQueue.status === "paused") && (
|
||||
<>
|
||||
<span className="flex items-center my-5 max-w-screen-lg">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Import Activity
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
|
||||
{/* Successful import counts */}
|
||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{importJobQueue.successfulJobCount}
|
||||
</dd>
|
||||
<dt className="text-lg font-medium text-gray-500">
|
||||
imported
|
||||
</dt>
|
||||
</div>
|
||||
{/* Failed job counts */}
|
||||
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
|
||||
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||
{importJobQueue.failedJobCount}
|
||||
</dd>
|
||||
<dt className="text-lg font-medium text-gray-500">
|
||||
failed
|
||||
</dt>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col dark:text-slate-200 text-slate-400">
|
||||
<dd>{renderQueueControls(importJobQueue.status)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="mt-2 dark:text-slate-200 text-slate-400">
|
||||
Imported: <span>{importJobQueue.mostRecentImport}</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Past imports */}
|
||||
{!isLoading && !isEmpty(data?.data) && (
|
||||
<div className="max-w-screen-lg">
|
||||
<span className="flex items-center mt-6">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Past Imports
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
|
||||
<div className="overflow-x-auto mt-4 rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||
<thead className="ltr:text-left rtl:text-right">
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Time Started
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Session Id
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Imported
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Failed
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data?.data.map((jobResult, id) => {
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{format(
|
||||
new Date(jobResult.earliestTimestamp),
|
||||
"EEEE, hh:mma, do LLLL Y",
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="tag is-warning">
|
||||
{jobResult.sessionId}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
|
||||
<span className="h-5 w-6">
|
||||
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
|
||||
</span>
|
||||
<p className="whitespace-nowrap text-sm">
|
||||
{jobResult.completedJobs}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
|
||||
<span className="h-5 w-6">
|
||||
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
|
||||
</span>
|
||||
|
||||
<p className="whitespace-nowrap text-sm">
|
||||
{jobResult.failedJobs}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Import;
|
||||
265
src/client/components/Library/Library.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useMemo, ReactElement, useState } 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 ellipsize from "ellipsize";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { format, fromUnixTime, parseISO } from "date-fns";
|
||||
|
||||
/**
|
||||
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* <Library />
|
||||
*/
|
||||
export const Library = (): ReactElement => {
|
||||
// Default page state
|
||||
// offset: 0
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// Method to fetch paginated issues
|
||||
const fetchIssues = async (searchQuery, offset, type) => {
|
||||
let pagination = {
|
||||
size: 15,
|
||||
from: offset,
|
||||
};
|
||||
return await axios({
|
||||
method: "POST",
|
||||
url: "http://localhost:3000/api/search/searchIssue",
|
||||
data: {
|
||||
searchQuery,
|
||||
pagination,
|
||||
type,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", offset],
|
||||
queryFn: () => fetchIssues({}, offset, "all"),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const searchResults = data?.data;
|
||||
|
||||
// Programmatically navigate to comic detail
|
||||
const navigate = useNavigate();
|
||||
const navigateToComicDetail = (row) => {
|
||||
navigate(`/comic/details/${row.original._id}`);
|
||||
};
|
||||
|
||||
const ComicInfoXML = (value) => {
|
||||
return value.data ? (
|
||||
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
|
||||
{/* Series Name */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{ellipsize(value.data.series[0], 45)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex flex-row mt-2 gap-2">
|
||||
{/* Pages */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{!isNil(value.data.number) && (
|
||||
<span>{parseInt(value.data.number[0], 10)}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : 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",
|
||||
cell: (info) =>
|
||||
!isEmpty(info.getValue()) ? (
|
||||
<ComicInfoXML data={info.getValue()} />
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Additional Metadata",
|
||||
columns: [
|
||||
{
|
||||
header: "Date of Import",
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info) => {
|
||||
return !isNil(info.getValue()) ? (
|
||||
<div className="text-xs w-max ml-3 my-3 text-slate-600">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Torrent: {info.getValue().torrent.downloads.length}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
setOffset(pageSize * pageIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||
}
|
||||
setOffset(from);
|
||||
};
|
||||
|
||||
// ImportStatus.propTypes = {
|
||||
// value: PropTypes.bool.isRequired,
|
||||
// };
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!isUndefined(searchResults?.hits) ? (
|
||||
<div>
|
||||
<div className="library">
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
paginationHandlers={{
|
||||
nextPage,
|
||||
previousPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no
|
||||
indices. Try importing a few comics into the library and come
|
||||
back.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
|
||||
{!isUndefined(searchResults?.data?.meta?.body) ? (
|
||||
<p>
|
||||
{JSON.stringify(
|
||||
searchResults.data.meta.body.error.root_cause,
|
||||
null,
|
||||
4,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 "../shared/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;
|
||||
67
src/client/components/Library/SearchBar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { searchIssue } from "../../actions/fileops.actions";
|
||||
|
||||
export const SearchBar = (): ReactElement => {
|
||||
const handleSubmit = useCallback((e) => {
|
||||
// dispatch(
|
||||
// searchIssue(
|
||||
// {
|
||||
// query: {
|
||||
// volumeName: e.search,
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// pagination: {
|
||||
// size: 25,
|
||||
// from: 0,
|
||||
// },
|
||||
// type: "volumeName",
|
||||
// trigger: "libraryPage",
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
}, []);
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={{}}
|
||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field name="search">
|
||||
{({ input, meta }) => {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
|
||||
<div className="w-10 text-gray-400">
|
||||
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...input}
|
||||
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
|
||||
type="text"
|
||||
id="search"
|
||||
placeholder="Type an issue/volume name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="sm:mt-0 rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
type="submit"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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,59 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { map } from "lodash";
|
||||
|
||||
interface MatchResultProps {
|
||||
matchData: any;
|
||||
}
|
||||
|
||||
export const MatchResult = (props: MatchResultProps) => {
|
||||
return (
|
||||
<>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(props.matchData, (match, idx) => {
|
||||
return (
|
||||
<tr className="search-result" key={idx}>
|
||||
<td>
|
||||
<img className="cover-image" src={match.image.thumb_url} />
|
||||
</td>
|
||||
<td className="search-result-details">
|
||||
<div className="tag score is-primary is-medium is-pulled-right">
|
||||
{parseFloat(match.score).toFixed(2)}
|
||||
</div>
|
||||
<div className="is-size-5">{match.name}</div>
|
||||
<div className="is-size-6">{match.volume.name}</div>
|
||||
|
||||
<div className="field is-grouped is-grouped-multiline">
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Number</span>
|
||||
<span className="tag is-primary">
|
||||
{match.issue_number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Type</span>
|
||||
<span className="tag is-warning">
|
||||
{match.resource_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchResult;
|
||||
127
src/client/components/PullList/PullList.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||
import T2Table from "../shared/T2Table";
|
||||
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||
import Card from "../shared/Carda";
|
||||
import ellipsize from "ellipsize";
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const PullList = (): ReactElement => {
|
||||
// const pullListComics = useSelector(
|
||||
// (state: RootState) => state.comicInfo.pullList,
|
||||
// );
|
||||
|
||||
useEffect(() => {
|
||||
// dispatch(
|
||||
// getWeeklyPullList({
|
||||
// startDate: "2023-7-28",
|
||||
// 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/Search.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { 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 "../shared/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;
|
||||
25
src/client/components/ServiceStatuses/ServiceStatuses.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useEffect } from "react";
|
||||
import { getServiceStatus } from "../../actions/fileops.actions";
|
||||
|
||||
export const ServiceStatuses = (): ReactElement => {
|
||||
const serviceStatus = useSelector(
|
||||
(state: RootState) => state.fileOps.libraryServiceStatus,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(getServiceStatus());
|
||||
}, []);
|
||||
return (
|
||||
<div className="is-clearfix">
|
||||
<div className="mt-4">
|
||||
<h3 className="title">Core Services</h3>
|
||||
<h6 className="subtitle has-text-grey-light">
|
||||
Statuses for core services
|
||||
</h6>
|
||||
</div>
|
||||
<pre>{JSON.stringify(serviceStatus, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import Select from "react-select";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../../store";
|
||||
import axios from "axios";
|
||||
|
||||
export const AirDCPPHubsForm = (): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation,
|
||||
} = useStore((state) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
}));
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
||||
});
|
||||
let hubList = {};
|
||||
if (!isNil(hubs)) {
|
||||
hubList = hubs.map(({ hub_url, identity }) => ({
|
||||
value: hub_url,
|
||||
label: identity.name,
|
||||
}));
|
||||
}
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
method: "POST",
|
||||
data: {
|
||||
settingsPayload: values,
|
||||
settingsObjectId: settings?.data._id,
|
||||
settingsKey: "directConnect",
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
const validate = async () => {};
|
||||
|
||||
const SelectAdapter = ({ input, ...rest }) => {
|
||||
return <Select {...input} {...rest} isClearable isMulti />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEmpty(hubList) && !isUndefined(hubs) ? (
|
||||
<Form
|
||||
onSubmit={mutate}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<article className="message">
|
||||
<div className="message-body">
|
||||
No configured hubs detected in AirDC++. <br />
|
||||
Configure to a hub in AirDC++ and then select a default hub here.
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
)}
|
||||
{!isEmpty(settings?.data.directConnect?.client.hubs) ? (
|
||||
<>
|
||||
<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>Default Hub For Searches:</h6>
|
||||
{settings?.data.directConnect?.client.hubs.map(
|
||||
({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div>{label}</div>
|
||||
<span className="is-size-7">{value}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirDCPPHubsForm;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
|
||||
const { settings } = settingsObject;
|
||||
return (
|
||||
<div>
|
||||
<span className="flex items-center mt-10 mb-4">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
AirDC++ Client Information
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<span className="inline-flex justify-center rounded-full bg-emerald-100 mb-4 px-2 py-0.5 text-emerald-700">
|
||||
<span className="h-5 w-6">
|
||||
<i className="icon-[solar--plug-circle-bold] h-5 w-5"></i>
|
||||
</span>
|
||||
<p className="whitespace-nowrap text-sm">Connected</p>
|
||||
</span>
|
||||
<p className="font-hasklig text-sm text-slate-700 dark:text-slate-700">
|
||||
<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:{" "}
|
||||
{JSON.stringify(settings.user.permissions, undefined, 2)}
|
||||
</dt>
|
||||
</dl>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirDCPPSettingsConfirmation;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||
import { isUndefined, isEmpty } from "lodash";
|
||||
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
||||
import { initializeAirDCPPSocket, useStore } from "../../../store/index";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export const AirDCPPSettingsForm = (): ReactElement => {
|
||||
// cherry-picking selectors for:
|
||||
// 1. initial values for the form
|
||||
// 2. If initial values are present, get the socket information to display
|
||||
const { setState } = useStore;
|
||||
const {
|
||||
airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSocketInstance,
|
||||
setAirDCPPSocketInstance,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketConnected: state.airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Mutation to update settings and subsequently initialize
|
||||
* AirDC++ socket with those settings
|
||||
*/
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
method: "POST",
|
||||
data: { settingsPayload: values, settingsKey: "directConnect" },
|
||||
}),
|
||||
onSuccess: async (values) => {
|
||||
const {
|
||||
data: {
|
||||
directConnect: {
|
||||
client: { host },
|
||||
},
|
||||
},
|
||||
} = values;
|
||||
const dcppSocketInstance = await initializeAirDCPPSocket(host);
|
||||
setState({
|
||||
airDCPPClientConfiguration: host,
|
||||
airDCPPSocketInstance: dcppSocketInstance,
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteSettingsMutation = useMutation(
|
||||
async () =>
|
||||
await axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: {},
|
||||
settingsKey: "directConnect",
|
||||
}),
|
||||
);
|
||||
|
||||
// const removeSettings = useCallback(async () => {
|
||||
// // airDCPPSettings.setSettings({});
|
||||
// }, []);
|
||||
//
|
||||
const initFormData = !isUndefined(airDCPPClientConfiguration)
|
||||
? airDCPPClientConfiguration
|
||||
: {};
|
||||
return (
|
||||
<>
|
||||
<ConnectionForm
|
||||
initialData={initFormData}
|
||||
submitHandler={mutate}
|
||||
formHeading={"Configure AirDC++"}
|
||||
/>
|
||||
|
||||
{!isEmpty(airDCPPSessionInformation) ? (
|
||||
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
|
||||
) : null}
|
||||
|
||||
{!isEmpty(airDCPPClientConfiguration) ? (
|
||||
<p className="control mt-4">
|
||||
as
|
||||
<button
|
||||
className="button is-danger"
|
||||
onClick={() => deleteSettingsMutation.mutate()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirDCPPSettingsForm;
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
||||
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export const QbittorrentConnectionForm = (): ReactElement => {
|
||||
const queryClient = new QueryClient();
|
||||
// fetch settings
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
console.log(data);
|
||||
const hostDetails = data?.data?.bittorrent?.client?.host;
|
||||
// connect to qbittorrent client
|
||||
const { data: connectionDetails } = useQuery({
|
||||
queryKey: [],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3060/api/qbittorrent/connect",
|
||||
method: "POST",
|
||||
data: hostDetails,
|
||||
}),
|
||||
enabled: !!hostDetails,
|
||||
});
|
||||
// get qbittorrent client info
|
||||
const { data: qbittorrentClientInfo } = useQuery({
|
||||
queryKey: ["qbittorrentClientInfo"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3060/api/qbittorrent/getClientInfo",
|
||||
method: "GET",
|
||||
}),
|
||||
enabled: !!connectionDetails,
|
||||
});
|
||||
// Update action using a mutation
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
method: "POST",
|
||||
data: { settingsPayload: values, settingsKey: "bittorrent" },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", "qbittorrentClientInfo"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<>
|
||||
<pre>Something went wrong connecting to qBittorrent.</pre>
|
||||
</>
|
||||
);
|
||||
if (!isLoading) {
|
||||
return (
|
||||
<>
|
||||
<ConnectionForm
|
||||
initialData={hostDetails}
|
||||
formHeading={"qBittorrent Configuration"}
|
||||
submitHandler={mutate}
|
||||
/>
|
||||
|
||||
<span className="flex items-center mt-10 mb-4">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
qBittorrent Client Information
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
|
||||
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<span className="inline-flex justify-center rounded-full bg-emerald-100 mb-4 px-2 py-0.5 text-emerald-700">
|
||||
<span className="h-5 w-6">
|
||||
<i className="icon-[solar--plug-circle-bold] h-5 w-5"></i>
|
||||
</span>
|
||||
<p className="whitespace-nowrap text-sm">Connected</p>
|
||||
</span>
|
||||
<pre className="font-hasklig text-sm text-slate-700 dark:text-slate-700">
|
||||
{JSON.stringify(qbittorrentClientInfo?.data, null, 4)}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default QbittorrentConnectionForm;
|
||||
142
src/client/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState, ReactElement } from "react";
|
||||
import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
|
||||
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
|
||||
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
|
||||
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
|
||||
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
|
||||
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: "qbt-connection",
|
||||
content: (
|
||||
<div key="qbt-connection">
|
||||
<QbittorrentConnectionForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "core-service",
|
||||
content: <>a</>,
|
||||
},
|
||||
{
|
||||
id: "flushdb",
|
||||
content: (
|
||||
<div key="flushdb">
|
||||
<SystemSettingsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Import comics into the ThreeTwo library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-cols max-w-screen-xl mx-auto">
|
||||
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{map(settingsObject, (settingObject, idx) => {
|
||||
return (
|
||||
<div
|
||||
className="w-64 py-2 text-slate-700 dark:text-slate-400"
|
||||
key={idx}
|
||||
>
|
||||
<h3 className="text-l pb-2">
|
||||
{settingObject.category.toUpperCase()}
|
||||
</h3>
|
||||
{/* First level children */}
|
||||
{!isUndefined(settingObject.children) ? (
|
||||
<ul key={settingObject.id}>
|
||||
{map(settingObject.children, (item, idx) => {
|
||||
return (
|
||||
<li key={idx} className="mb-2">
|
||||
<a
|
||||
className={
|
||||
item.id.toString() === active
|
||||
? "is-active flex items-center"
|
||||
: "flex items-center"
|
||||
}
|
||||
onClick={() => setActive(item.id.toString())}
|
||||
>
|
||||
{item.displayName}
|
||||
</a>
|
||||
{/* Second level children */}
|
||||
{!isUndefined(item.children) ? (
|
||||
<ul className="pl-4 mt-2">
|
||||
{map(item.children, (item, idx) => (
|
||||
<li key={item.id} className="mb-2">
|
||||
<a
|
||||
className={
|
||||
item.id.toString() === active
|
||||
? "is-active flex items-center"
|
||||
: "flex items-center"
|
||||
}
|
||||
onClick={() =>
|
||||
setActive(item.id.toString())
|
||||
}
|
||||
>
|
||||
{item.displayName}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
|
||||
{/* content for settings */}
|
||||
<div className="max-w-screen-xl">
|
||||
<div className="content">
|
||||
{map(settingsContent, ({ id, content }) =>
|
||||
active === id ? content : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export const SystemSettingsForm = (): ReactElement => {
|
||||
const { mutate: flushDb, isLoading } = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/library/flushDb`,
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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={
|
||||
isLoading ? "button is-danger is-loading" : "button is-danger"
|
||||
}
|
||||
onClick={() => flushDb()}
|
||||
>
|
||||
<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 "../shared/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 "../shared/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;
|
||||
183
src/client/components/Volumes/Volumes.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||
import { searchIssue } from "../../actions/fileops.actions";
|
||||
import Card from "../shared/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);
|
||||
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;
|
||||
178
src/client/components/WantedComics/WantedComics.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||
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,
|
||||
// );
|
||||
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;
|
||||
@@ -4,13 +4,17 @@ import {
|
||||
removeLeadingPeriod,
|
||||
escapePoundSymbol,
|
||||
} from "../shared/utils/formatting.utils";
|
||||
import { isUndefined, isEmpty } from "lodash";
|
||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
interface IProps {
|
||||
comicBookCoversMetadata: IExtractedComicBookCoverFile;
|
||||
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
|
||||
mongoObjId?: number;
|
||||
hasTitle: boolean;
|
||||
title?: string;
|
||||
isHorizontal: boolean;
|
||||
}
|
||||
interface IState {}
|
||||
|
||||
@@ -22,34 +26,30 @@ class Card extends React.Component<IProps, IState> {
|
||||
public drawCoverCard = (
|
||||
metadata: IExtractedComicBookCoverFile,
|
||||
): JSX.Element => {
|
||||
const filePath = encodeURI(
|
||||
"http://localhost:3000" +
|
||||
removeLeadingPeriod(metadata.path) +
|
||||
"/" +
|
||||
metadata.name,
|
||||
const encodedFilePath = encodeURI(
|
||||
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
|
||||
);
|
||||
|
||||
const filePath = escapePoundSymbol(encodedFilePath);
|
||||
return (
|
||||
<div>
|
||||
<div className="card generic-card">
|
||||
<div>
|
||||
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
|
||||
<div className="card-image">
|
||||
<figure className="image">
|
||||
<img
|
||||
src={escapePoundSymbol(filePath)}
|
||||
alt="Placeholder image"
|
||||
/>
|
||||
<img src={filePath} alt="Placeholder image" />
|
||||
</figure>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<ul>
|
||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
||||
<li className="has-text-weight-semibold">
|
||||
{ellipsize(metadata.name, 18)}
|
||||
</li>
|
||||
</Link>
|
||||
</ul>
|
||||
</div>
|
||||
{this.props.hasTitle && (
|
||||
<div className="card-content">
|
||||
<ul>
|
||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
||||
<li className="has-text-weight-semibold">
|
||||
{ellipsize(metadata.name, 18)}
|
||||
</li>
|
||||
</Link>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
171
src/client/components/shared/Carda.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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: ICardProps): 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>
|
||||
);
|
||||
|
||||
case "vertical-2":
|
||||
return (
|
||||
<div className="block rounded-md w-fit h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
|
||||
<img
|
||||
alt="Home"
|
||||
src={props.imageUrl}
|
||||
className="rounded-t-md object-cover"
|
||||
/>
|
||||
|
||||
<div className="mt-2 px-2">
|
||||
<dl>
|
||||
<div>
|
||||
<dd className="text-md text-slate-500 dark:text-black">
|
||||
{props.title}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{props.hasDetails && <>{props.children}</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "horizontal-small":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<img src={props.imageUrl} className="object-cover h-20 w-20" />
|
||||
</div>
|
||||
{/* details */}
|
||||
<div className="w-fit h-fit pl-1 pr-2 py-1">
|
||||
<p className="text-sm">{props.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "horizontal-medium":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<img src={props.imageUrl} />
|
||||
</div>
|
||||
{/* details */}
|
||||
<div className="pl-1 pr-2 py-1">
|
||||
<p className="text-sm">{props.title}</p>
|
||||
{props.hasDetails && <>{props.children}</>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "cover-only":
|
||||
return (
|
||||
<>
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit">
|
||||
<img src={props.imageUrl} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "card-with-info-panel":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row">
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-md overflow-hidden w-fit h-fit">
|
||||
<img src={props.imageUrl} />
|
||||
</div>
|
||||
{/* myata-dyata */}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export const Card = React.memo(
|
||||
(props: ICardProps): ReactElement => renderCard(props),
|
||||
);
|
||||
|
||||
export default Card;
|
||||
158
src/client/components/shared/ConnectionForm/ConnectionForm.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { hostNameValidator } from "../../../shared/utils/validator.utils";
|
||||
import { isEmpty } from "lodash";
|
||||
|
||||
export const ConnectionForm = ({
|
||||
initialData,
|
||||
submitHandler,
|
||||
formHeading,
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={submitHandler}
|
||||
initialValues={initialData}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit} className="mt-10">
|
||||
<h2 className="text-xl">{formHeading}</h2>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>Configure your AirDC++ client connection here.</p>
|
||||
<p>
|
||||
Note that you need an instance of AirDC++ already running to
|
||||
use this form to connect to it.
|
||||
</p>
|
||||
<p>
|
||||
See{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="http://airdcpp.net/docs/installation/installation.html"
|
||||
>
|
||||
here
|
||||
</a>{" "}
|
||||
for AirDC++ installation instructions for various platforms.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<span className="flex items-center mt-6">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Configure Connection
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
|
||||
<div className="flex flex-row mt-4">
|
||||
<div className="relative">
|
||||
{/* protocol */}
|
||||
<label className="block py-1">Protocol</label>
|
||||
<Field
|
||||
name="protocol"
|
||||
component="select"
|
||||
className="appearance-none dark:bg-slate-400 bg-slate-100 h-10 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
>
|
||||
<option>Protocol</option>
|
||||
<option value="http">http://</option>
|
||||
<option value="https">https://</option>
|
||||
</Field>
|
||||
<div className="absolute h-7 w-7 right-0 px-1 top-11 pointer-events-none">
|
||||
<i className="icon-[solar--alt-arrow-down-bold]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* hostname */}
|
||||
<Field name="hostname" validate={hostNameValidator}>
|
||||
{({ input, meta }) => (
|
||||
<div className="flex flex-col">
|
||||
<label className="block px-2 py-1">Hostname</label>
|
||||
<input
|
||||
{...input}
|
||||
type="text"
|
||||
placeholder="Hostname"
|
||||
className="ml-2 dark:bg-slate-400 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
/>
|
||||
<div>
|
||||
{meta.error && meta.touched && (
|
||||
<span className="text-sm text-red-400 px-2">
|
||||
{meta.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* port */}
|
||||
<div className="flex flex-col">
|
||||
<label className="block px-2 py-1">Port</label>
|
||||
<Field
|
||||
name="port"
|
||||
component="input"
|
||||
className="ml-2 dark:bg-slate-400 bg-slate-100 px-2 block h-10 rounded-md sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row mt-5">
|
||||
<div>
|
||||
<label className="block py-1">Username</label>
|
||||
<div className="relative">
|
||||
<Field
|
||||
name="username"
|
||||
component="input"
|
||||
className="h-10 dark:bg-slate-500 bg-slate-200 rounded-md text-gray-700 dark:text-slate-200 py-1 px-10 mr-5 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Username"
|
||||
/>
|
||||
<span className="absolute h-6 w-6 left-2 top-2 inset-y-0 flex items-center px-0 pointer-events-none">
|
||||
<i className="icon-[solar--user-bold-duotone] h-6 w-6 dark:text-slate-200" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<label className="block py-1">Password</label>
|
||||
<div className="relative">
|
||||
<Field
|
||||
name="password"
|
||||
component="input"
|
||||
type="password"
|
||||
className="h-10 dark:bg-slate-500 bg-slate-200 rounded-md text-gray-700 dark:text-slate-200 py-1 px-10 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<span className="absolute left-2 top-2 inset-y-0 flex items-center px-0 pointer-events-none h-6 w-6">
|
||||
<i className="icon-[solar--lock-password-bold-duotone] h-6 w-6 dark:text-slate-200" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
type="submit"
|
||||
>
|
||||
<span className="text-md">
|
||||
{!isEmpty(initialData) ? "Update" : "Save"}
|
||||
</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--diskette-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
>
|
||||
{!isEmpty(initialData) && "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
src/client/components/shared/Draggable/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";
|
||||
94
src/client/components/shared/Draggable/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;
|
||||
17
src/client/components/shared/Draggable/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>
|
||||
);
|
||||
}
|
||||
32
src/client/components/shared/Draggable/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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
5
src/client/components/shared/ErrorPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
export const ErrorPage = () => {
|
||||
return <>Error has been encountered.</>;
|
||||
};
|
||||
20
src/client/components/shared/Header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
type IHeaderProps = {
|
||||
headerContent: string;
|
||||
subHeaderContent: string;
|
||||
iconClassNames: string;
|
||||
};
|
||||
|
||||
export const Header = (props: IHeaderProps): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<h4 className="title is-4">
|
||||
<i className={props.iconClassNames}></i> {props.headerContent}
|
||||
</h4>
|
||||
<p className="subtitle is-7">{props.subHeaderContent}</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||